@chrischall/gemini-mcp 2.3.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { basename, delimiter } from 'node:path';
1
+ import { basename } 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';
@@ -11,6 +11,7 @@ import { createBlobJobStore } from './job-store.js';
11
11
  import { SessionState } from './session.js';
12
12
  import { fetchRemoteImage, readCapped, MAX_REDIRECTS } from './fetch-image.js';
13
13
  import { bytesToBase64 } from './bytes.js';
14
+ import { explainOutsideUploadDir, uploadRoots } from './images.js';
14
15
  // NOTE: this module must stay SIDE-EFFECT-FREE at module scope — no I/O, no
15
16
  // top-level await, no `import.meta.url`. `src/worker.ts` imports it, so every
16
17
  // line here runs during Cloudflare isolate startup, where global-scope I/O is
@@ -181,29 +182,6 @@ export function normalizeFileName(ref) {
181
182
  }
182
183
  return match[1];
183
184
  }
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
- }
207
185
  export class GeminiClient {
208
186
  /**
209
187
  * A key handed in explicitly (the connector's per-session key). When absent
package/dist/images.js CHANGED
@@ -1,7 +1,89 @@
1
- import { mkdir, readFile, writeFile, access, stat, open } from 'node:fs/promises';
1
+ import { readFile, stat } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
- import { join, resolve, isAbsolute } from 'node:path';
4
- import { readEnvVar, McpToolError } from '@chrischall/mcp-utils';
3
+ import { delimiter, join, resolve, isAbsolute } from 'node:path';
4
+ import { assertPathWithinRoots, expandPath, fileBlob, readEnvVar, McpToolError, readFileHead, resolveOutputDir as resolveSharedOutputDir, sniffMimeBytes as sniffSharedMime, writeFileSafe, writeUniqueFile, } from '@chrischall/mcp-utils';
5
+ /**
6
+ * `GEMINI_UPLOAD_DIR` — the optional allow-list for local files streamed to
7
+ * the Files API (`gemini_upload_file` `path`, `video_path`). One or more
8
+ * directories separated by the platform path delimiter (`:` / `;`); `~` is
9
+ * expanded by mcp-utils. Unset → `undefined` → uploads stay unconfined.
10
+ * The paths are model-chosen, so a prompt-injected path could otherwise send
11
+ * any readable file to Google.
12
+ */
13
+ export function uploadRoots() {
14
+ const roots = readEnvVar('GEMINI_UPLOAD_DIR')?.split(delimiter).map((r) => r.trim()).filter(Boolean);
15
+ return roots && roots.length > 0 ? roots : undefined;
16
+ }
17
+ /**
18
+ * Turn mcp-utils' bare "outside the allowed directories" refusal into an
19
+ * actionable tool error. The remediation is in the MESSAGE — hosts show the
20
+ * message and drop the hint (see CLAUDE.md, Errors).
21
+ */
22
+ export function explainOutsideUploadDir(err) {
23
+ if (err instanceof Error && err.message.startsWith('Path is outside the allowed directories')) {
24
+ 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.');
25
+ }
26
+ throw err;
27
+ }
28
+ /** A path-delimiter list from `name`, trimmed, blanks dropped; `undefined` when empty. */
29
+ function rootList(name, env) {
30
+ const roots = readEnvVar(name, { env })?.split(delimiter).map((r) => r.trim()).filter(Boolean);
31
+ return roots && roots.length > 0 ? roots : undefined;
32
+ }
33
+ /**
34
+ * The folders a local REFERENCE image (`images` / `image_path` paths) may be
35
+ * read from. Those bytes go to Google — inline, or as a Files API upload once a
36
+ * path is reused (inputs.ts) — and the path is model-chosen, so a
37
+ * prompt-injected path could otherwise send any readable file. In order:
38
+ *
39
+ * 1. `GEMINI_REFERENCE_DIR` — one or more folders (`:` / `;` separated, `~` ok);
40
+ * 2. else `GEMINI_UPLOAD_DIR` — the existing allow-list for local files sent to
41
+ * the Files API, so an operator who restricted uploads gets reference images
42
+ * restricted too;
43
+ * 3. else, hosted (`MCP_DATA_DIR` set) — only `$MCP_DATA_DIR/uploads`: never the
44
+ * runner's home, nor the data dir itself (it holds tokens and job state);
45
+ * 4. else `undefined` — a local install with nothing configured stays
46
+ * unconfined (the user's own files, on their own machine).
47
+ *
48
+ * When confinement is on, `GEMINI_OUTPUT_DIR` (operator config) is added, so
49
+ * this server's own generated images can always be fed back in as references.
50
+ */
51
+ export function referenceRoots(env = process.env) {
52
+ let roots = rootList('GEMINI_REFERENCE_DIR', env) ?? rootList('GEMINI_UPLOAD_DIR', env);
53
+ if (!roots) {
54
+ const dataDir = readEnvVar('MCP_DATA_DIR', { env });
55
+ if (dataDir)
56
+ roots = [join(dataDir, 'uploads')];
57
+ }
58
+ if (!roots)
59
+ return undefined;
60
+ const output = readEnvVar('GEMINI_OUTPUT_DIR', { env })?.trim();
61
+ return output && !roots.includes(output) ? [...roots, output] : roots;
62
+ }
63
+ /** mcp-utils' bare out-of-roots refusal → an actionable reference-image error. */
64
+ function explainOutsideReferenceDir(err, roots) {
65
+ if (err instanceof Error && err.message.startsWith('Path is outside the allowed directories')) {
66
+ throw new McpToolError(`Refusing to send a reference image from outside the allowed folders (${roots.join(', ')}). ` +
67
+ 'Reference images are confined by GEMINI_REFERENCE_DIR (else GEMINI_UPLOAD_DIR; on a hosted server, $MCP_DATA_DIR/uploads). ' +
68
+ 'Move the image into one of those folders, or add its folder to GEMINI_REFERENCE_DIR (outside GEMINI_REFERENCE_DIR is refused).');
69
+ }
70
+ throw err;
71
+ }
72
+ /**
73
+ * Refuse a resolved reference-image path outside {@link referenceRoots} (no-op
74
+ * when unconfined). Checked through symlinks, before the file is stat'ed or read.
75
+ */
76
+ export function assertReferenceAllowed(resolvedPath) {
77
+ const roots = referenceRoots();
78
+ if (!roots)
79
+ return;
80
+ try {
81
+ assertPathWithinRoots(resolvedPath, roots);
82
+ }
83
+ catch (err) {
84
+ explainOutsideReferenceDir(err, roots);
85
+ }
86
+ }
5
87
  /** URL/file-safe slug from a prompt; never empty. */
6
88
  export function slugify(text, max = 40) {
7
89
  const s = text
@@ -12,25 +94,6 @@ export function slugify(text, max = 40) {
12
94
  .replace(/-+$/g, '');
13
95
  return s || 'image';
14
96
  }
15
- async function exists(p) {
16
- try {
17
- await access(p);
18
- return true;
19
- }
20
- catch {
21
- return false;
22
- }
23
- }
24
- /** `<base>.<ext>`, then `<base>-2.<ext>`, … until a free path is found. */
25
- export async function uniquePath(dir, base, ext) {
26
- let candidate = join(dir, `${base}.${ext}`);
27
- let n = 2;
28
- while (await exists(candidate)) {
29
- candidate = join(dir, `${base}-${n}.${ext}`);
30
- n++;
31
- }
32
- return candidate;
33
- }
34
97
  /** Sniff MIME type from the first bytes of an image buffer (PNG fallback).
35
98
  * Only for bytes that arrive as base64 with no other type information; a LOCAL
36
99
  * file goes through {@link localImageMime}, which never guesses. */
@@ -51,23 +114,17 @@ function localImageMime(resolvedPath, head) {
51
114
  }
52
115
  return mime;
53
116
  }
54
- /** PNG/JPEG/WebP from the leading bytes, or `undefined` when it is none of them. */
117
+ /** The image types Gemini takes as reference images — the only sniff results honoured here. */
118
+ const REFERENCE_IMAGE_MIMES = new Set(['image/png', 'image/jpeg', 'image/webp']);
119
+ /**
120
+ * PNG/JPEG/WebP from the leading bytes (mcp-utils' shared magic-byte sniffer),
121
+ * or `undefined` when it is none of them. The shared sniffer also names GIF,
122
+ * PDF, zip, MIDI and ISO-BMFF; those are deliberately NOT accepted here, so a
123
+ * reference image is still only ever one of the three types Gemini takes.
124
+ */
55
125
  function sniffImageMime(buf) {
56
- // PNG: 89 50 4E 47
57
- if (buf.length >= 4 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
58
- return 'image/png';
59
- }
60
- // JPEG: FF D8 FF
61
- if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
62
- return 'image/jpeg';
63
- }
64
- // WebP: RIFF....WEBP (bytes 0-3 = RIFF, bytes 8-11 = WEBP)
65
- if (buf.length >= 12 &&
66
- buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 &&
67
- buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) {
68
- return 'image/webp';
69
- }
70
- return undefined;
126
+ const mime = sniffSharedMime(buf);
127
+ return mime !== undefined && REFERENCE_IMAGE_MIMES.has(mime) ? mime : undefined;
71
128
  }
72
129
  /** File extension for a media MIME (image/video/audio). Falls back to the MIME
73
130
  * subtype, so an unforeseen preview type still writes with a sane extension. */
@@ -94,11 +151,19 @@ export function mediaExt(mimeType) {
94
151
  return m.split('/')[1]?.split(';')[0]?.replace(/^x-/, '') || 'bin';
95
152
  }
96
153
  /** Decode base64 media bytes (image/video/audio) and write to disk (creating
97
- * dir), picking the extension from the MIME. Returns the absolute path. */
154
+ * dir), picking the extension from the MIME. Returns the absolute path.
155
+ *
156
+ * Never overwrites: `<base>.<ext>`, then `<base>-2.<ext>`, … — each name is
157
+ * claimed with an exclusive, no-follow create (mcp-utils `writeUniqueFile`), so
158
+ * two writers can't race onto one name and a symlink planted at a name is
159
+ * skipped rather than written through. */
98
160
  export async function writeMedia(dir, base, base64, mimeType) {
99
- await mkdir(dir, { recursive: true });
100
- const path = await uniquePath(dir, base, mediaExt(mimeType));
101
- await writeFile(path, Buffer.from(base64, 'base64'));
161
+ const path = await writeUniqueFile({
162
+ dir,
163
+ baseName: base,
164
+ extension: mediaExt(mimeType),
165
+ bytes: Buffer.from(base64, 'base64'),
166
+ });
102
167
  return resolve(path);
103
168
  }
104
169
  /** Decode base64 image bytes and write to disk. Returns the absolute path. */
@@ -113,7 +178,8 @@ export async function writeImage(dir, base, base64, mimeType) {
113
178
  * multi-turn chain is recoverable from disk.
114
179
  */
115
180
  export async function writeSidecar(imagePath, data) {
116
- await writeFile(`${imagePath}.json`, JSON.stringify(data, null, 2));
181
+ // overwrite (a re-run may refresh it) but never through a planted symlink.
182
+ await writeFileSafe(`${imagePath}.json`, Buffer.from(JSON.stringify(data, null, 2)), { overwrite: true });
117
183
  }
118
184
  /**
119
185
  * Resolve an input file path, checking (in order):
@@ -174,6 +240,19 @@ export function videoMimeType(p) {
174
240
  }
175
241
  return mime;
176
242
  }
243
+ /**
244
+ * The first 16 bytes of a local reference image, confined to
245
+ * {@link referenceRoots} when those are set.
246
+ */
247
+ async function readLocalHead(path) {
248
+ const roots = referenceRoots();
249
+ try {
250
+ return await readFileHead(path, 16, { ...(roots ? { allowedRoots: roots } : {}) });
251
+ }
252
+ catch (err) {
253
+ return roots ? explainOutsideReferenceDir(err, roots) : Promise.reject(err);
254
+ }
255
+ }
177
256
  /**
178
257
  * Preview a local IMAGE input WITHOUT sending it anywhere: resolve the path to
179
258
  * an absolute one, sniff its mime from the leading bytes, and measure its size.
@@ -182,19 +261,12 @@ export function videoMimeType(p) {
182
261
  */
183
262
  export async function previewImageInput(path) {
184
263
  const resolved = resolveImagePath(path);
264
+ assertReferenceAllowed(resolved);
185
265
  // Read only the header bytes needed to sniff the MIME (≤12), and take the size
186
266
  // from stat() — a preview must not load a large reference image into memory
187
267
  // just to report it (matches previewVideoInput).
188
268
  const { size } = await stat(resolved);
189
- const fh = await open(resolved, 'r');
190
- try {
191
- const head = Buffer.alloc(16);
192
- const { bytesRead } = await fh.read(head, 0, 16, 0);
193
- return { path: resolved, mimeType: localImageMime(resolved, head.subarray(0, bytesRead)), size };
194
- }
195
- finally {
196
- await fh.close();
197
- }
269
+ return { path: resolved, mimeType: localImageMime(resolved, await readLocalHead(resolved)), size };
198
270
  }
199
271
  /**
200
272
  * Preview a local VIDEO input WITHOUT uploading it: resolve the path, derive its
@@ -226,17 +298,6 @@ const UPLOAD_MIME_BY_EXT = {
226
298
  flac: 'audio/flac',
227
299
  ...VIDEO_MIME_BY_EXT,
228
300
  };
229
- async function readHead(path, n = 16) {
230
- const fh = await open(path, 'r');
231
- try {
232
- const head = Buffer.alloc(n);
233
- const { bytesRead } = await fh.read(head, 0, n, 0);
234
- return head.subarray(0, bytesRead);
235
- }
236
- finally {
237
- await fh.close();
238
- }
239
- }
240
301
  /**
241
302
  * The MIME a local file should be uploaded to the Files API as, or `undefined`
242
303
  * when it cannot be identified. Bytes win for the image formats we can sniff
@@ -245,19 +306,53 @@ async function readHead(path, n = 16) {
245
306
  * to label every unrecognised file image/png (chrischall/fleet-audit#117).
246
307
  */
247
308
  export async function detectUploadMime(resolvedPath) {
248
- const sniffed = sniffImageMime(await readHead(resolvedPath));
309
+ // Confined to GEMINI_UPLOAD_DIR when set — the same roots the upload itself
310
+ // (fileBlob in client.ts) enforces, so an out-of-root path is refused here,
311
+ // before the confirmation preview, rather than after it.
312
+ const roots = uploadRoots();
313
+ let head;
314
+ try {
315
+ head = await readFileHead(resolvedPath, 16, { ...(roots ? { allowedRoots: roots } : {}) });
316
+ }
317
+ catch (err) {
318
+ explainOutsideUploadDir(err);
319
+ }
320
+ const sniffed = sniffImageMime(head);
249
321
  if (sniffed)
250
322
  return sniffed;
251
323
  const ext = resolvedPath.includes('.') ? resolvedPath.slice(resolvedPath.lastIndexOf('.') + 1).toLowerCase() : '';
252
324
  return UPLOAD_MIME_BY_EXT[ext];
253
325
  }
254
- /** Read an image file into `{ base64, mimeType }` for an inline_data part. */
326
+ /**
327
+ * Read a model-supplied reference image into `{ base64, mimeType }` for an
328
+ * inline_data part, confined to {@link referenceRoots} when those are set
329
+ * (mcp-utils `fileBlob` with `allowedRoots`, checked through symlinks).
330
+ */
255
331
  export async function readImageAsInline(path) {
256
332
  const resolved = resolveImagePath(path);
257
- const buf = await readFile(resolved);
333
+ const roots = referenceRoots();
334
+ let buf;
335
+ try {
336
+ buf = Buffer.from(await (await fileBlob(resolved, { label: 'Image', ...(roots ? { allowedRoots: roots } : {}) })).arrayBuffer());
337
+ }
338
+ catch (err) {
339
+ if (roots)
340
+ explainOutsideReferenceDir(err, roots);
341
+ throw err;
342
+ }
258
343
  const mimeType = localImageMime(resolved, buf);
259
344
  return { base64: buf.toString('base64'), mimeType };
260
345
  }
346
+ /**
347
+ * Read an image THIS server wrote (an interaction's output, found by its
348
+ * sidecar under the GEMINI_OUTPUT_DIR-confined {@link lookupOutputDir}) for
349
+ * re-anchoring a chain. Not model-chosen, so not subject to the reference
350
+ * roots — a chain must recover its own output wherever output_dir put it.
351
+ */
352
+ export async function readOutputImageAsInline(path) {
353
+ const buf = await readFile(path);
354
+ return { base64: buf.toString('base64'), mimeType: localImageMime(path, buf) };
355
+ }
261
356
  /**
262
357
  * Accept either a data URI (`data:image/png;base64,XXXX`) or raw base64.
263
358
  * For raw base64, MIME is sniffed from the decoded bytes.
@@ -293,7 +388,36 @@ export function baseName(name) {
293
388
  .replace(/^-+|-+$/g, '');
294
389
  return safe || 'image';
295
390
  }
296
- /** per-call → $GEMINI_OUTPUT_DIR → cwd. */
391
+ /**
392
+ * The output directory to LOOK IN (sidecar lookups: `continue_last`'s disk
393
+ * fallback, the chain-404 re-anchor) — the same resolution and the same
394
+ * GEMINI_OUTPUT_DIR confinement as {@link resolveOutputDir}, but read-only:
395
+ * it never creates the directory, so a lookup that writes nothing leaves no
396
+ * empty folder behind. A missing directory simply has no sidecars.
397
+ */
398
+ export function lookupOutputDir(perCall) {
399
+ const trimmed = perCall?.trim() || undefined;
400
+ const configured = readEnvVar('GEMINI_OUTPUT_DIR');
401
+ const raw = trimmed ?? configured;
402
+ if (!raw)
403
+ return process.cwd();
404
+ const dir = expandPath(raw);
405
+ if (trimmed && configured)
406
+ assertPathWithinRoots(dir, [configured]);
407
+ return dir;
408
+ }
409
+ /**
410
+ * per-call → $GEMINI_OUTPUT_DIR → cwd (mcp-utils `resolveOutputDir`: `~` and
411
+ * relative paths expanded, the directory created; a per-call dir confined to
412
+ * GEMINI_OUTPUT_DIR when that is set). A blank per-call value
413
+ * counts as unset, so it falls through to the env var rather than to cwd.
414
+ */
297
415
  export function resolveOutputDir(perCall) {
298
- return perCall?.trim() || readEnvVar('GEMINI_OUTPUT_DIR') || process.cwd();
416
+ // output_dir is model-chosen: once the operator sets GEMINI_OUTPUT_DIR, a
417
+ // per-call directory must stay inside it (checked through symlinks). Unset
418
+ // keeps the old, unconfined behaviour (the fleet pattern, as in splitwise-mcp).
419
+ const configured = readEnvVar('GEMINI_OUTPUT_DIR');
420
+ return resolveSharedOutputDir(perCall?.trim() || undefined, 'GEMINI_OUTPUT_DIR', {
421
+ ...(configured ? { allowedRoots: [configured] } : {}),
422
+ });
299
423
  }
package/dist/inputs.js CHANGED
@@ -131,7 +131,7 @@ export async function resolveImageInputs(args, client) {
131
131
  * key rather than silently reusing an upload of the old content.
132
132
  */
133
133
  async function resolveLocalPath(path, client, onDisk, report) {
134
- const { readImageAsInline, resolveImagePath } = await import('./images.js');
134
+ const { readImageAsInline, resolveImagePath, assertReferenceAllowed } = await import('./images.js');
135
135
  // A runtime with no filesystem never gets here — `assertLocalInputsAvailable`
136
136
  // rejects `images` before any of this — but if it somehow did, reading the
137
137
  // file is what would fail, so keep the pre-existing error path.
@@ -139,6 +139,8 @@ async function resolveLocalPath(path, client, onDisk, report) {
139
139
  return readImageAsInline(path);
140
140
  const { stat } = await import('node:fs/promises');
141
141
  const resolved = resolveImagePath(path);
142
+ // Before the stat, the read, and any Files API promotion below.
143
+ assertReferenceAllowed(resolved);
142
144
  let key;
143
145
  try {
144
146
  const { mtimeMs, size } = await stat(resolved);
@@ -17,7 +17,7 @@ import { signedLinks } from '../signed-url.js';
17
17
  import { tenantResolver } from '../tenant.js';
18
18
  /**
19
19
  * The stdio sink: `resolveOutputDir(output_dir)` → `writeMedia` per item, in
20
- * order (so `uniquePath`'s `name`/`name-2`/`name-3` sequencing is preserved).
20
+ * order (so the `name`/`name-2`/`name-3` sequencing is preserved).
21
21
  *
22
22
  * `node:fs` is reached through a dynamic import so this module stays loadable
23
23
  * in a Worker, where the R2 sink is the only one ever constructed.
@@ -30,8 +30,9 @@ export function createDiskSink() {
30
30
  const { writeMedia, resolveOutputDir } = await import('../images.js');
31
31
  const dir = resolveOutputDir(opts.output_dir);
32
32
  const refs = [];
33
- // Sequential on purpose: uniquePath checks the filesystem, so writing two
34
- // colliding names concurrently would race onto the same path.
33
+ // Sequential on purpose: it keeps the `name`, `name-2`, `name-3` numbering
34
+ // in item order (writeMedia's exclusive create makes concurrency safe,
35
+ // but not ordered).
35
36
  for (const it of items)
36
37
  refs.push({ ref: await writeMedia(dir, it.base, it.base64, it.mimeType) });
37
38
  return refs;
@@ -24,7 +24,7 @@ export function registerGenerateTools(server, client) {
24
24
  prompt: z.string().min(1).describe('Text prompt describing the image'),
25
25
  count: z.number().int().positive().max(8).optional().describe('Number of independent images (default 1)'),
26
26
  filename: z.string().optional().describe('Base filename for the output image (extension stripped; default: slugified prompt)'),
27
- images: z.array(z.string().min(1)).optional().describe('Paths to reference input images (image-conditioned generation)'),
27
+ images: z.array(z.string().min(1)).optional().describe('Paths to reference input images (image-conditioned generation). Local paths must be inside GEMINI_REFERENCE_DIR (else GEMINI_UPLOAD_DIR) when set.'),
28
28
  images_url: imagesUrlSchema(),
29
29
  images_file_uris: imagesFileUrisSchema(),
30
30
  images_r2_keys: imagesR2KeysSchema(),
@@ -169,7 +169,7 @@ export function registerGenerateTools(server, client) {
169
169
  annotations: { readOnlyHint: false, openWorldHint: true },
170
170
  inputSchema: z.object({
171
171
  prompt: z.string().min(1).describe('Instruction describing the edit or composition'),
172
- images: z.array(z.string().min(1)).optional().describe('Paths to input image file(s) (1 = edit, 2+ = compose)'),
172
+ images: z.array(z.string().min(1)).optional().describe('Paths to input image file(s) (1 = edit, 2+ = compose). Local paths must be inside GEMINI_REFERENCE_DIR (else GEMINI_UPLOAD_DIR) when set.'),
173
173
  images_url: imagesUrlSchema('Input images'),
174
174
  images_file_uris: imagesFileUrisSchema('Input images'),
175
175
  images_r2_keys: imagesR2KeysSchema('Input images'),
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { McpToolError, ApiError, readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveModel } from '../models.js';
4
4
  import { ChainedRequest404Error } from '../client.js';
5
- import { slugify, baseName, resolveImagePath, writeSidecar, resolveOutputDir, readImageAsInline } from '../images.js';
5
+ import { slugify, baseName, resolveImagePath, writeSidecar, lookupOutputDir, readOutputImageAsInline } from '../images.js';
6
6
  import { resolveImageInputs } from '../inputs.js';
7
7
  import { findInteractionImages, latestInteractionId } from '../sidecar.js';
8
8
  import { emit, reportShape, resolveAspectRatio, orientationSchema, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesBase64Schema } from './shared.js';
@@ -45,7 +45,7 @@ function splitReattachedOutputs(images, writtenOutputs) {
45
45
  */
46
46
  async function reanchorFromDisk(outputDir, interactionId) {
47
47
  const refs = await findInteractionImages(outputDir, interactionId);
48
- return { refs, carried: await Promise.all(refs.map((p) => readImageAsInline(p))) };
48
+ return { refs, carried: await Promise.all(refs.map((p) => readOutputImageAsInline(p))) };
49
49
  }
50
50
  /** The same, from the object store, for a deployment with no filesystem. */
51
51
  async function reanchorFromStore(client, interactionId) {
@@ -105,7 +105,7 @@ export function registerInteractTools(server, client) {
105
105
  images: z
106
106
  .array(z.string().min(1))
107
107
  .optional()
108
- .describe(`Paths to reference input images. ${NEW_REFERENCES_ONLY}`),
108
+ .describe(`Paths to reference input images. Local paths must be inside GEMINI_REFERENCE_DIR (else GEMINI_UPLOAD_DIR) when set. ${NEW_REFERENCES_ONLY}`),
109
109
  images_url: imagesUrlSchema(`Reference images. ${NEW_REFERENCES_ONLY} Given`),
110
110
  images_file_uris: imagesFileUrisSchema(`Reference images. ${NEW_REFERENCES_ONLY} Given`),
111
111
  images_base64: imagesBase64Schema(`Reference images. ${NEW_REFERENCES_ONLY} Given`),
@@ -183,7 +183,7 @@ export function registerInteractTools(server, client) {
183
183
  // record — an `<image>.json` on disk, an object beside the media in
184
184
  // the store — so a restart no longer ends a chain on either.
185
185
  previousInteractionId = onDisk
186
- ? await latestInteractionId(resolveOutputDir(args.output_dir))
186
+ ? await latestInteractionId(lookupOutputDir(args.output_dir))
187
187
  // 'image' explicitly: video and music write records too, and a
188
188
  // chained Lyria call is a documented 400. Resuming the wrong
189
189
  // medium's chain is worse than not resuming.
@@ -274,7 +274,7 @@ export function registerInteractTools(server, client) {
274
274
  // `<image>.json` sidecars; hosted it is the record beside each
275
275
  // stored object. Same rule either way: no match, no guess.
276
276
  const { refs: reanchorOn, carried } = onDisk
277
- ? await reanchorFromDisk(resolveOutputDir(args.output_dir), err.previousInteractionId)
277
+ ? await reanchorFromDisk(lookupOutputDir(args.output_dir), err.previousInteractionId)
278
278
  : await reanchorFromStore(client, err.previousInteractionId);
279
279
  if (!reanchorOn.length)
280
280
  throw err;
@@ -34,7 +34,7 @@ export function registerMusicTools(server, client) {
34
34
  inputSchema: z.object({
35
35
  prompt: z.string().min(1).describe('Description of the music: mood, genre, instruments, tempo, structure, or lyrics'),
36
36
  model: z.enum(MUSIC_MODELS).optional().describe(`Lyria model (default: ${DEFAULT_MUSIC_MODEL} — 30s, $0.04). lyria-3.5 and lyria-3-pro-preview run minutes-long at $0.08.`),
37
- images: z.array(z.string().min(1)).optional().describe('Optional reference image path(s) to condition the music'),
37
+ images: z.array(z.string().min(1)).optional().describe('Optional reference image path(s) to condition the music. Local paths must be inside GEMINI_REFERENCE_DIR (else GEMINI_UPLOAD_DIR) when set.'),
38
38
  images_url: imagesUrlSchema('Reference images'),
39
39
  images_file_uris: imagesFileUrisSchema('Reference images'),
40
40
  images_base64: imagesBase64Schema(),
package/dist/tools/set.js CHANGED
@@ -20,7 +20,7 @@ export function registerSetTools(server, client) {
20
20
  count: z.number().int().positive().max(8).optional().describe('Number of variations of master_prompt (when scenes omitted)'),
21
21
  reference_mode: z.enum(['master', 'chain']).optional().describe('master: every image references the master (default). chain: each references the previous.'),
22
22
  basename: z.string().optional().describe('Base filename prefix for output images (default: slugified master_prompt)'),
23
- master_images: z.array(z.string().min(1)).optional().describe('Reference image paths passed to the master generation call'),
23
+ master_images: z.array(z.string().min(1)).optional().describe('Reference image paths passed to the master generation call. Local paths must be inside GEMINI_REFERENCE_DIR (else GEMINI_UPLOAD_DIR) when set.'),
24
24
  master_images_url: imagesUrlSchema('Reference images passed to the master AND to every scene call (fetched once)'),
25
25
  master_images_file_uris: imagesFileUrisSchema('Reference images passed to the master AND to every scene call'),
26
26
  master_images_r2_keys: imagesR2KeysSchema('Reference images passed to the master AND to every scene call'),
@@ -45,7 +45,7 @@ export function registerVideoTools(server, client) {
45
45
  orientation: orientationSchema,
46
46
  task: z.enum(VIDEO_TASKS).optional().describe('text_to_video (default), image_to_video / reference_to_video (need image input), or edit / extend (need previous_interaction_id)'),
47
47
  resolution: z.enum(VIDEO_RESOLUTIONS).optional().describe('Output resolution (default 720p). Video is billed per output token, so 360p costs roughly a third of 720p — use it for drafts'),
48
- images: z.array(z.string().min(1)).optional().describe('Reference image path(s) for image_to_video / reference_to_video'),
48
+ images: z.array(z.string().min(1)).optional().describe('Reference image path(s) for image_to_video / reference_to_video. Local paths must be inside GEMINI_REFERENCE_DIR (else GEMINI_UPLOAD_DIR) when set.'),
49
49
  images_url: imagesUrlSchema('Reference stills'),
50
50
  images_file_uris: imagesFileUrisSchema('Reference stills'),
51
51
  images_base64: imagesBase64Schema(),
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.3.0'; // x-release-please-version
2
+ export const VERSION = '2.3.1'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/gemini-mcp",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
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>",
@@ -46,13 +46,13 @@
46
46
  "typecheck": "tsc -p tsconfig.test.json"
47
47
  },
48
48
  "dependencies": {
49
- "@chrischall/mcp-utils": "^2.6.0",
50
- "@modelcontextprotocol/server": "^2.0.0",
49
+ "@chrischall/mcp-utils": "^2.13.0",
50
+ "@modelcontextprotocol/server": "^2.2.0",
51
51
  "dotenv": "^18.0.2",
52
52
  "zod": "^4.6.5"
53
53
  },
54
54
  "devDependencies": {
55
- "@modelcontextprotocol/client": "^2.0.0",
55
+ "@modelcontextprotocol/client": "^2.2.0",
56
56
  "@types/node": "^26.0.0",
57
57
  "@vitest/coverage-v8": "^5.0.0",
58
58
  "esbuild": "^0.28.0",
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.3.0",
9
+ "version": "2.3.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "2.3.0",
14
+ "version": "2.3.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -50,6 +50,13 @@
50
50
  "isRequired": false,
51
51
  "format": "string",
52
52
  "isSecret": false
53
+ },
54
+ {
55
+ "name": "GEMINI_REFERENCE_DIR",
56
+ "description": "Restrict local reference images (images / image paths sent to Gemini) to these directories (path.delimiter-separated), plus GEMINI_OUTPUT_DIR. Unset: GEMINI_UPLOAD_DIR applies; if that is unset too, unrestricted locally, and $MCP_DATA_DIR/uploads when MCP_DATA_DIR is set (hosted)",
57
+ "isRequired": false,
58
+ "format": "string",
59
+ "isSecret": false
53
60
  }
54
61
  ]
55
62
  }