@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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -1
- package/dist/bundle.js +1095 -353
- package/dist/client.js +2 -24
- package/dist/images.js +192 -68
- package/dist/inputs.js +3 -1
- package/dist/storage/media.js +4 -3
- package/dist/tools/generate.js +2 -2
- package/dist/tools/interact.js +5 -5
- package/dist/tools/music.js +1 -1
- package/dist/tools/set.js +1 -1
- package/dist/tools/video.js +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -4
- package/server.json +9 -2
package/dist/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { basename
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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);
|
package/dist/storage/media.js
CHANGED
|
@@ -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
|
|
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:
|
|
34
|
-
//
|
|
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;
|
package/dist/tools/generate.js
CHANGED
|
@@ -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'),
|
package/dist/tools/interact.js
CHANGED
|
@@ -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,
|
|
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) =>
|
|
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(
|
|
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(
|
|
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;
|
package/dist/tools/music.js
CHANGED
|
@@ -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'),
|
package/dist/tools/video.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
50
|
-
"@modelcontextprotocol/server": "^2.
|
|
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.
|
|
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.
|
|
9
|
+
"version": "2.3.1",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/gemini-mcp",
|
|
14
|
-
"version": "2.3.
|
|
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
|
}
|