@chrischall/gemini-mcp 2.1.3 → 2.1.4

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.1.3"
10
+ "version": "2.1.4"
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.1.3",
18
+ "version": "2.1.4",
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.1.3",
4
+ "version": "2.1.4",
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
@@ -59233,6 +59233,7 @@ var images_exports = {};
59233
59233
  __export(images_exports, {
59234
59234
  baseName: () => baseName,
59235
59235
  decodeImageInput: () => decodeImageInput,
59236
+ detectUploadMime: () => detectUploadMime,
59236
59237
  mediaExt: () => mediaExt,
59237
59238
  previewImageInput: () => previewImageInput,
59238
59239
  previewVideoInput: () => previewVideoInput,
@@ -59272,6 +59273,9 @@ async function uniquePath(dir, base, ext) {
59272
59273
  return candidate;
59273
59274
  }
59274
59275
  function sniffMimeBytes(buf) {
59276
+ return sniffImageMime(buf) ?? "image/png";
59277
+ }
59278
+ function sniffImageMime(buf) {
59275
59279
  if (buf.length >= 4 && buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) {
59276
59280
  return "image/png";
59277
59281
  }
@@ -59281,7 +59285,7 @@ function sniffMimeBytes(buf) {
59281
59285
  if (buf.length >= 12 && buf[0] === 82 && buf[1] === 73 && buf[2] === 70 && buf[3] === 70 && buf[8] === 87 && buf[9] === 69 && buf[10] === 66 && buf[11] === 80) {
59282
59286
  return "image/webp";
59283
59287
  }
59284
- return "image/png";
59288
+ return void 0;
59285
59289
  }
59286
59290
  function mediaExt(mimeType) {
59287
59291
  const m = mimeType.toLowerCase();
@@ -59356,6 +59360,22 @@ async function previewVideoInput(path) {
59356
59360
  const { size } = await stat(resolved);
59357
59361
  return { path: resolved, mimeType: videoMimeType(resolved), size };
59358
59362
  }
59363
+ async function readHead(path, n = 16) {
59364
+ const fh = await open2(path, "r");
59365
+ try {
59366
+ const head = Buffer.alloc(n);
59367
+ const { bytesRead } = await fh.read(head, 0, n, 0);
59368
+ return head.subarray(0, bytesRead);
59369
+ } finally {
59370
+ await fh.close();
59371
+ }
59372
+ }
59373
+ async function detectUploadMime(resolvedPath) {
59374
+ const sniffed = sniffImageMime(await readHead(resolvedPath));
59375
+ if (sniffed) return sniffed;
59376
+ const ext = resolvedPath.includes(".") ? resolvedPath.slice(resolvedPath.lastIndexOf(".") + 1).toLowerCase() : "";
59377
+ return UPLOAD_MIME_BY_EXT[ext];
59378
+ }
59359
59379
  async function readImageAsInline(path) {
59360
59380
  const resolved = resolveImagePath(path);
59361
59381
  const buf = await readFile(resolved);
@@ -59383,7 +59403,7 @@ function baseName(name) {
59383
59403
  function resolveOutputDir(perCall) {
59384
59404
  return perCall?.trim() || readEnvVar("GEMINI_OUTPUT_DIR") || process.cwd();
59385
59405
  }
59386
- var VIDEO_MIME_BY_EXT;
59406
+ var VIDEO_MIME_BY_EXT, UPLOAD_MIME_BY_EXT;
59387
59407
  var init_images = __esm({
59388
59408
  "src/images.ts"() {
59389
59409
  "use strict";
@@ -59400,6 +59420,23 @@ var init_images = __esm({
59400
59420
  "3gp": "video/3gpp",
59401
59421
  "3gpp": "video/3gpp"
59402
59422
  };
59423
+ UPLOAD_MIME_BY_EXT = {
59424
+ png: "image/png",
59425
+ jpg: "image/jpeg",
59426
+ jpeg: "image/jpeg",
59427
+ webp: "image/webp",
59428
+ gif: "image/gif",
59429
+ heic: "image/heic",
59430
+ heif: "image/heif",
59431
+ wav: "audio/wav",
59432
+ mp3: "audio/mp3",
59433
+ aiff: "audio/aiff",
59434
+ aif: "audio/aiff",
59435
+ aac: "audio/aac",
59436
+ ogg: "audio/ogg",
59437
+ flac: "audio/flac",
59438
+ ...VIDEO_MIME_BY_EXT
59439
+ };
59403
59440
  }
59404
59441
  });
59405
59442
 
@@ -59476,7 +59513,7 @@ async function loadStdioDotenv() {
59476
59513
  }
59477
59514
 
59478
59515
  // src/version.ts
59479
- var VERSION = "2.1.3";
59516
+ var VERSION = "2.1.4";
59480
59517
 
59481
59518
  // src/errors.ts
59482
59519
  init_dist2();
@@ -60087,11 +60124,11 @@ async function resolveImageInputs(args, client2) {
60087
60124
  async function resolveLocalPath(path, client2, onDisk, report) {
60088
60125
  const { readImageAsInline: readImageAsInline2, resolveImagePath: resolveImagePath2 } = await Promise.resolve().then(() => (init_images(), images_exports));
60089
60126
  if (!onDisk) return readImageAsInline2(path);
60090
- const { stat: stat3 } = await import("node:fs/promises");
60127
+ const { stat: stat4 } = await import("node:fs/promises");
60091
60128
  const resolved = resolveImagePath2(path);
60092
60129
  let key;
60093
60130
  try {
60094
- const { mtimeMs, size } = await stat3(resolved);
60131
+ const { mtimeMs, size } = await stat4(resolved);
60095
60132
  key = `path\0${resolved}\0${mtimeMs}\0${size}`;
60096
60133
  } catch {
60097
60134
  return readImageAsInline2(path);
@@ -60194,6 +60231,61 @@ function signedLinks(baseUrl) {
60194
60231
  };
60195
60232
  }
60196
60233
 
60234
+ // src/tenant.ts
60235
+ var TENANT_PIN_KEY = "lib/tenant.json";
60236
+ var TENANT_SHAPE = /^[0-9a-f]{12}$/;
60237
+ async function readPin(bucket) {
60238
+ const object4 = await bucket.get(TENANT_PIN_KEY);
60239
+ if (!object4) return void 0;
60240
+ try {
60241
+ const parsed = JSON.parse(new TextDecoder().decode(await object4.arrayBuffer()));
60242
+ return typeof parsed.tenant === "string" && TENANT_SHAPE.test(parsed.tenant) ? parsed.tenant : void 0;
60243
+ } catch {
60244
+ return void 0;
60245
+ }
60246
+ }
60247
+ function pinnedTenant(bucket, derive) {
60248
+ let cached2;
60249
+ async function resolve4() {
60250
+ const pinned = await readPin(bucket);
60251
+ if (pinned) return { tenant: pinned, pinned: true };
60252
+ const tenant = await derive();
60253
+ try {
60254
+ await bucket.put(TENANT_PIN_KEY, JSON.stringify({ tenant }), {
60255
+ httpMetadata: { contentType: "application/json" }
60256
+ });
60257
+ return { tenant, pinned: true };
60258
+ } catch {
60259
+ return { tenant, pinned: false };
60260
+ }
60261
+ }
60262
+ return () => {
60263
+ if (cached2) return cached2;
60264
+ const attempt = resolve4().then(({ tenant, pinned }) => {
60265
+ if (!pinned && cached2 === attempt) cached2 = void 0;
60266
+ return tenant;
60267
+ });
60268
+ cached2 = attempt;
60269
+ attempt.catch(() => {
60270
+ if (cached2 === attempt) cached2 = void 0;
60271
+ });
60272
+ return attempt;
60273
+ };
60274
+ }
60275
+ function tenantResolver(source) {
60276
+ let cache;
60277
+ return () => {
60278
+ if (cache) return cache;
60279
+ const attempt = Promise.resolve(
60280
+ typeof source === "function" ? source() : source
60281
+ );
60282
+ attempt.catch(() => {
60283
+ if (cache === attempt) cache = void 0;
60284
+ });
60285
+ return cache = attempt;
60286
+ };
60287
+ }
60288
+
60197
60289
  // src/storage/media.ts
60198
60290
  function createDiskSink() {
60199
60291
  return {
@@ -60251,8 +60343,7 @@ function createR2Sink(bucket, opts) {
60251
60343
  const signedBase = opts.links?.base ?? opts.signedBaseUrl?.replace(/\/+$/, "");
60252
60344
  const ttlMs = opts.urlTtlMs ?? 0;
60253
60345
  const readPrefixes = [prefix, ...opts.readPrefixes ?? []];
60254
- let tenantCache;
60255
- const tenantId = () => tenantCache ??= Promise.resolve(typeof opts.tenant === "function" ? opts.tenant() : opts.tenant);
60346
+ const tenantId = tenantResolver(opts.tenant);
60256
60347
  const ownPrefix = async () => {
60257
60348
  const tenant = await tenantId();
60258
60349
  return tenant ? `${prefix}/${tenant}/` : `${prefix}/`;
@@ -61975,8 +62066,7 @@ function isValidLibraryName(name) {
61975
62066
  }
61976
62067
  function createR2Library(bucket, opts) {
61977
62068
  const now = opts.now ?? (() => /* @__PURE__ */ new Date());
61978
- let tenantCache;
61979
- const tenantId = () => tenantCache ??= Promise.resolve(typeof opts.tenant === "function" ? opts.tenant() : opts.tenant);
62069
+ const tenantId = tenantResolver(opts.tenant);
61980
62070
  const assertName = (name) => {
61981
62071
  if (!isValidLibraryName(name)) {
61982
62072
  throw new Error(`Invalid library name "${name}" \u2014 use 1-40 lowercase letters, digits, "-" or "_" (e.g. "finn", "bold-cartoon-sports").`);
@@ -62583,6 +62673,17 @@ var GeminiClient = class {
62583
62673
  const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: "Video" });
62584
62674
  return this.uploadToFilesApi(blob, mimeType, basename(path), blob.size);
62585
62675
  }
62676
+ /**
62677
+ * Upload ANY local file (image, video, audio) by streaming it from disk —
62678
+ * the {@link uploadVideo} path without the video label. `gemini_upload_file`
62679
+ * `path` uses this: reading the file into a Buffer, base64-encoding it and
62680
+ * decoding it back held ~3x the file in memory and hit V8's max string
62681
+ * length on a few-hundred-MB video (chrischall/fleet-audit#117).
62682
+ */
62683
+ async uploadFile(path, mimeType, displayName = basename(path)) {
62684
+ const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: "File" });
62685
+ return this.uploadToFilesApi(blob, mimeType, displayName, blob.size);
62686
+ }
62586
62687
  /**
62587
62688
  * Upload already-in-memory bytes (a fetched `images_url`, a `data_base64`
62588
62689
  * payload, an inline `gemini_upload_file` body) to the Files API. Same three
@@ -63208,10 +63309,9 @@ function isTransientPoll(err) {
63208
63309
  function isDeliveryUnsupported(err) {
63209
63310
  return err instanceof ApiError && err.status === 400 && /delivery/i.test(err.message);
63210
63311
  }
63211
- function hostedStorage() {
63212
- const blob = blobStoreFromEnv();
63312
+ function hostedStorage(blob = blobStoreFromEnv()) {
63213
63313
  if (!blob) return {};
63214
- const tenant = () => tenantIdFor(readEnvVar("GEMINI_API_KEY") ?? "local");
63314
+ const tenant = pinnedTenant(blob.bucket, () => tenantIdFor(readEnvVar("GEMINI_API_KEY") ?? "local"));
63215
63315
  return {
63216
63316
  mediaSink: createR2Sink(blob.bucket, {
63217
63317
  // The SAME `links` object the minter gets below. Media GETs and upload
@@ -63781,6 +63881,7 @@ function registerUsageTools(server, client2) {
63781
63881
  // src/tools/files.ts
63782
63882
  init_zod();
63783
63883
  init_dist2();
63884
+ import { stat as stat3 } from "node:fs/promises";
63784
63885
  var UPLOAD_URL_MAX_BYTES_DISK = 100 * 1024 * 1024;
63785
63886
  var UPLOAD_URL_MAX_BYTES_HOSTED = 25 * 1024 * 1024;
63786
63887
  var UPLOAD_ACCEPT = ["image/", "video/", "audio/"];
@@ -63831,6 +63932,7 @@ function registerFileTools(server, client2) {
63831
63932
  sources.length === 0 ? "Provide exactly one of `url`, `data_base64`" + (onDisk ? " or `path`." : " or `r2_key`.") : `Provide exactly one source, not ${sources.length} (${sources.join(", ")}).`
63832
63933
  );
63833
63934
  }
63935
+ let localUpload;
63834
63936
  if (args.path) {
63835
63937
  if (!onDisk) {
63836
63938
  throw new McpToolError(
@@ -63838,13 +63940,22 @@ function registerFileTools(server, client2) {
63838
63940
  { hint: "Use `url`, `r2_key` (gemini_get_upload_url), or `data_base64`." }
63839
63941
  );
63840
63942
  }
63841
- const gate = await previewLocalInputsUnlessConfirmed(
63842
- args.confirm,
63843
- "Upload a local file to the Gemini Files API",
63844
- "/upload/v1beta/files",
63845
- [args.path]
63846
- );
63847
- if (gate) return gate;
63943
+ const { resolveImagePath: resolveImagePath2, detectUploadMime: detectUploadMime2 } = await Promise.resolve().then(() => (init_images(), images_exports));
63944
+ const resolved = resolveImagePath2(args.path);
63945
+ const mimeType = args.mime_type ?? await detectUploadMime2(resolved);
63946
+ if (!mimeType) {
63947
+ throw new McpToolError(
63948
+ `Cannot tell what kind of file ${resolved} is from its bytes or extension \u2014 pass \`mime_type\` (e.g. video/mp4, audio/mp3, image/gif) so it is not uploaded under the wrong type.`,
63949
+ { hint: "Pass mime_type explicitly." }
63950
+ );
63951
+ }
63952
+ if (args.confirm !== true) {
63953
+ const { size } = await stat3(resolved);
63954
+ return previewUnlessConfirmed(args.confirm, "Upload a local file to the Gemini Files API", "POST", "/upload/v1beta/files", {
63955
+ inputs: [{ path: resolved, mimeType, size }]
63956
+ });
63957
+ }
63958
+ localUpload = { path: resolved, mimeType };
63848
63959
  }
63849
63960
  const uploaded = await withProgressHeartbeat(extra, "Uploading to the Gemini Files API", async () => {
63850
63961
  if (args.url) {
@@ -63873,17 +63984,14 @@ function registerFileTools(server, client2) {
63873
63984
  if (args.data_base64) {
63874
63985
  const { decodeImageInput: decodeImageInput2 } = await Promise.resolve().then(() => (init_images(), images_exports));
63875
63986
  const decoded = decodeImageInput2(args.data_base64);
63876
- const bytes2 = base64ToBytes(decoded.base64);
63877
- return client2.uploadBytes(bytes2, args.mime_type ?? decoded.mimeType, args.display_name ?? "upload");
63878
- }
63879
- const { readImageAsInline: readImageAsInline2, resolveImagePath: resolveImagePath2 } = await Promise.resolve().then(() => (init_images(), images_exports));
63880
- const resolved = resolveImagePath2(args.path);
63881
- const inline = await readImageAsInline2(resolved);
63882
- const bytes = base64ToBytes(inline.base64);
63883
- return client2.uploadBytes(
63884
- bytes,
63885
- args.mime_type ?? inline.mimeType,
63886
- args.display_name ?? (resolved.split(/[\\/]/).pop() || "upload")
63987
+ const bytes = base64ToBytes(decoded.base64);
63988
+ return client2.uploadBytes(bytes, args.mime_type ?? decoded.mimeType, args.display_name ?? "upload");
63989
+ }
63990
+ const local = localUpload;
63991
+ return client2.uploadFile(
63992
+ local.path,
63993
+ local.mimeType,
63994
+ args.display_name ?? (local.path.split(/[\\/]/).pop() || "upload")
63887
63995
  );
63888
63996
  });
63889
63997
  return uploadResult(uploaded);
package/dist/client.js CHANGED
@@ -6,6 +6,7 @@ import { createDiskSink, createR2Sink, tenantIdFor } from './storage/media.js';
6
6
  import { createR2Library } from './library.js';
7
7
  import { createUploadUrlMinter } from './upload-url.js';
8
8
  import { blobStoreFromEnv } from './blob-store.js';
9
+ import { pinnedTenant } from './tenant.js';
9
10
  import { createBlobJobStore } from './job-store.js';
10
11
  import { SessionState } from './session.js';
11
12
  import { fetchRemoteImage, readCapped, MAX_REDIRECTS } from './fetch-image.js';
@@ -367,6 +368,17 @@ export class GeminiClient {
367
368
  const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: 'Video' });
368
369
  return this.uploadToFilesApi(blob, mimeType, basename(path), blob.size);
369
370
  }
371
+ /**
372
+ * Upload ANY local file (image, video, audio) by streaming it from disk —
373
+ * the {@link uploadVideo} path without the video label. `gemini_upload_file`
374
+ * `path` uses this: reading the file into a Buffer, base64-encoding it and
375
+ * decoding it back held ~3x the file in memory and hit V8's max string
376
+ * length on a few-hundred-MB video (chrischall/fleet-audit#117).
377
+ */
378
+ async uploadFile(path, mimeType, displayName = basename(path)) {
379
+ const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: 'File' });
380
+ return this.uploadToFilesApi(blob, mimeType, displayName, blob.size);
381
+ }
370
382
  /**
371
383
  * Upload already-in-memory bytes (a fetched `images_url`, a `data_base64`
372
384
  * payload, an inline `gemini_upload_file` body) to the Files API. Same three
@@ -1155,15 +1167,18 @@ function isDeliveryUnsupported(err) {
1155
1167
  *
1156
1168
  * Side-effect-free at module scope: reading two env vars and closing over them.
1157
1169
  */
1158
- function hostedStorage() {
1159
- const blob = blobStoreFromEnv();
1170
+ export function hostedStorage(blob = blobStoreFromEnv()) {
1160
1171
  if (!blob)
1161
1172
  return {};
1162
- // Namespaced by API key like the connector was. One registration is one
1163
- // user here, so this is belt-and-braces rather than the tenancy boundary —
1164
- // that is the blob store's per-registration key. Lazy because the key is
1165
- // read at request time, not at construction (deferred-config-error).
1166
- const tenant = () => tenantIdFor(readEnvVar('GEMINI_API_KEY') ?? 'local');
1173
+ // One registration is one user here, so the namespace is belt-and-braces
1174
+ // rather than the tenancy boundary — that is the blob store's
1175
+ // per-registration key. It must be STABLE, though: it used to be a hash of
1176
+ // GEMINI_API_KEY, so rotating the key orphaned the whole library, every job
1177
+ // record and every chain sidecar (chrischall/fleet-audit#116). It is now
1178
+ // pinned in the store on first use — derived from the current key so the
1179
+ // data already there stays reachable — and never re-derived after that.
1180
+ // Lazy because the key is read at request time (deferred-config-error).
1181
+ const tenant = pinnedTenant(blob.bucket, () => tenantIdFor(readEnvVar('GEMINI_API_KEY') ?? 'local'));
1167
1182
  return {
1168
1183
  mediaSink: createR2Sink(blob.bucket, {
1169
1184
  // The SAME `links` object the minter gets below. Media GETs and upload
package/dist/images.js CHANGED
@@ -31,8 +31,12 @@ 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. */
34
+ /** Sniff MIME type from the first bytes of an image buffer (PNG fallback). */
35
35
  function sniffMimeBytes(buf) {
36
+ return sniffImageMime(buf) ?? 'image/png';
37
+ }
38
+ /** PNG/JPEG/WebP from the leading bytes, or `undefined` when it is none of them. */
39
+ function sniffImageMime(buf) {
36
40
  // PNG: 89 50 4E 47
37
41
  if (buf.length >= 4 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
38
42
  return 'image/png';
@@ -47,7 +51,7 @@ function sniffMimeBytes(buf) {
47
51
  buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) {
48
52
  return 'image/webp';
49
53
  }
50
- return 'image/png';
54
+ return undefined;
51
55
  }
52
56
  /** File extension for a media MIME (image/video/audio). Falls back to the MIME
53
57
  * subtype, so an unforeseen preview type still writes with a sane extension. */
@@ -185,6 +189,52 @@ export async function previewVideoInput(path) {
185
189
  const { size } = await stat(resolved);
186
190
  return { path: resolved, mimeType: videoMimeType(resolved), size };
187
191
  }
192
+ /**
193
+ * Image and audio types the Files API accepts, by extension. Video comes from
194
+ * {@link VIDEO_MIME_BY_EXT} so the two lists cannot disagree.
195
+ */
196
+ const UPLOAD_MIME_BY_EXT = {
197
+ png: 'image/png',
198
+ jpg: 'image/jpeg',
199
+ jpeg: 'image/jpeg',
200
+ webp: 'image/webp',
201
+ gif: 'image/gif',
202
+ heic: 'image/heic',
203
+ heif: 'image/heif',
204
+ wav: 'audio/wav',
205
+ mp3: 'audio/mp3',
206
+ aiff: 'audio/aiff',
207
+ aif: 'audio/aiff',
208
+ aac: 'audio/aac',
209
+ ogg: 'audio/ogg',
210
+ flac: 'audio/flac',
211
+ ...VIDEO_MIME_BY_EXT,
212
+ };
213
+ async function readHead(path, n = 16) {
214
+ const fh = await open(path, 'r');
215
+ try {
216
+ const head = Buffer.alloc(n);
217
+ const { bytesRead } = await fh.read(head, 0, n, 0);
218
+ return head.subarray(0, bytesRead);
219
+ }
220
+ finally {
221
+ await fh.close();
222
+ }
223
+ }
224
+ /**
225
+ * The MIME a local file should be uploaded to the Files API as, or `undefined`
226
+ * when it cannot be identified. Bytes win for the image formats we can sniff
227
+ * (a PNG saved as `.jpg` is still a PNG); otherwise the extension decides,
228
+ * over image, video AND audio types. Never guesses: `gemini_upload_file` used
229
+ * to label every unrecognised file image/png (chrischall/fleet-audit#117).
230
+ */
231
+ export async function detectUploadMime(resolvedPath) {
232
+ const sniffed = sniffImageMime(await readHead(resolvedPath));
233
+ if (sniffed)
234
+ return sniffed;
235
+ const ext = resolvedPath.includes('.') ? resolvedPath.slice(resolvedPath.lastIndexOf('.') + 1).toLowerCase() : '';
236
+ return UPLOAD_MIME_BY_EXT[ext];
237
+ }
188
238
  /** Read an image file into `{ base64, mimeType }` for an inline_data part. */
189
239
  export async function readImageAsInline(path) {
190
240
  const resolved = resolveImagePath(path);
package/dist/library.js CHANGED
@@ -29,6 +29,7 @@
29
29
  *
30
30
  * Worker-safe: no node imports, no module-scope state, structural bucket type.
31
31
  */
32
+ import { tenantResolver } from './tenant.js';
32
33
  /** Key prefix for the library — the one prefix the retention sweep must skip. */
33
34
  export const LIBRARY_KEY_PREFIX = 'lib';
34
35
  /**
@@ -42,8 +43,8 @@ export function isValidLibraryName(name) {
42
43
  /** Build the R2-backed library for one session. */
43
44
  export function createR2Library(bucket, opts) {
44
45
  const now = opts.now ?? (() => new Date());
45
- let tenantCache;
46
- const tenantId = () => (tenantCache ??= Promise.resolve(typeof opts.tenant === 'function' ? opts.tenant() : opts.tenant));
46
+ // Cached per library; a rejected resolution is retried, not cached.
47
+ const tenantId = tenantResolver(opts.tenant);
47
48
  const assertName = (name) => {
48
49
  if (!isValidLibraryName(name)) {
49
50
  throw new Error(`Invalid library name "${name}" — use 1-40 lowercase letters, digits, "-" or "_" (e.g. "finn", "bold-cartoon-sports").`);
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { base64ToBytes } from '../bytes.js';
16
16
  import { signedLinks } from '../signed-url.js';
17
+ import { tenantResolver } from '../tenant.js';
17
18
  /**
18
19
  * The stdio sink: `resolveOutputDir(output_dir)` → `writeMedia` per item, in
19
20
  * order (so `uniquePath`'s `name`/`name-2`/`name-3` sequencing is preserved).
@@ -141,8 +142,8 @@ export function createR2Sink(bucket, opts) {
141
142
  const signedBase = opts.links?.base ?? opts.signedBaseUrl?.replace(/\/+$/, '');
142
143
  const ttlMs = opts.urlTtlMs ?? 0;
143
144
  const readPrefixes = [prefix, ...(opts.readPrefixes ?? [])];
144
- let tenantCache;
145
- const tenantId = () => (tenantCache ??= Promise.resolve(typeof opts.tenant === 'function' ? opts.tenant() : opts.tenant));
145
+ // Cached per sink; a rejected resolution is retried, not cached.
146
+ const tenantId = tenantResolver(opts.tenant);
146
147
  /** `<prefix>/` or `<prefix>/<tenant>/` — where every WRITE goes. */
147
148
  const ownPrefix = async () => {
148
149
  const tenant = await tenantId();
package/dist/tenant.js ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The hosted namespace (`lib/<tenant>/`, `jobs/<tenant>/`, `gen/<tenant>/`,
3
+ * `up/<tenant>/`), pinned once per registration.
4
+ *
5
+ * It used to be `tenantIdFor(GEMINI_API_KEY)`, recomputed every process. That
6
+ * made the API key — the one credential the healthcheck tells the user to
7
+ * rotate on `credential_rejected` — the address of everything persistent: a
8
+ * rotation silently moved the whole namespace, and the character/style library
9
+ * (advertised as having NO expiry), durable job records and chain sidecars all
10
+ * came back empty (chrischall/fleet-audit#116).
11
+ *
12
+ * The real tenancy boundary is the blob store's per-registration key, so the
13
+ * tenant only needs to be stable, not secret-derived. It is now read from a
14
+ * small pin object in the registration's own store; the first process to find
15
+ * none derives it the old way and writes it. Deriving from the CURRENT key on
16
+ * first use is deliberate: that is exactly where every existing deployment's
17
+ * data already lives, so the change needs no migration.
18
+ */
19
+ /** Under `lib/` so the host's retention prune never reclaims it (blob-store.ts). */
20
+ export const TENANT_PIN_KEY = 'lib/tenant.json';
21
+ /** Same shape `tenantIdFor` produces; anything else in the pin is ignored. */
22
+ const TENANT_SHAPE = /^[0-9a-f]{12}$/;
23
+ async function readPin(bucket) {
24
+ const object = await bucket.get(TENANT_PIN_KEY);
25
+ if (!object)
26
+ return undefined;
27
+ try {
28
+ const parsed = JSON.parse(new TextDecoder().decode(await object.arrayBuffer()));
29
+ return typeof parsed.tenant === 'string' && TENANT_SHAPE.test(parsed.tenant) ? parsed.tenant : undefined;
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ }
35
+ /**
36
+ * A memoised tenant resolver for the hosted stores.
37
+ *
38
+ * Only a fully successful resolution is cached: a failed read rejects and is
39
+ * retried on the next call, and a failed pin WRITE still returns the derived
40
+ * tenant (so a transient blob hiccup does not fail the tool call) but leaves
41
+ * the pin to be written next time.
42
+ */
43
+ export function pinnedTenant(bucket, derive) {
44
+ let cached;
45
+ async function resolve() {
46
+ const pinned = await readPin(bucket);
47
+ if (pinned)
48
+ return { tenant: pinned, pinned: true };
49
+ const tenant = await derive();
50
+ try {
51
+ await bucket.put(TENANT_PIN_KEY, JSON.stringify({ tenant }), {
52
+ httpMetadata: { contentType: 'application/json' },
53
+ });
54
+ return { tenant, pinned: true };
55
+ }
56
+ catch {
57
+ return { tenant, pinned: false };
58
+ }
59
+ }
60
+ return () => {
61
+ if (cached)
62
+ return cached;
63
+ const attempt = resolve().then(({ tenant, pinned }) => {
64
+ if (!pinned && cached === attempt)
65
+ cached = undefined;
66
+ return tenant;
67
+ });
68
+ cached = attempt;
69
+ attempt.catch(() => {
70
+ if (cached === attempt)
71
+ cached = undefined;
72
+ });
73
+ return attempt;
74
+ };
75
+ }
76
+ /**
77
+ * Memoise a tenant source (a static value, or a possibly-async resolver) for
78
+ * one sink/library. Concurrent callers share one in-flight resolution and a
79
+ * success is cached for the process, but a REJECTED resolution is dropped, not
80
+ * cached: the hosted tenant is read from the store (`pinnedTenant`), and one
81
+ * transient failure must not wedge its consumer for the rest of the process.
82
+ */
83
+ export function tenantResolver(source) {
84
+ let cache;
85
+ return () => {
86
+ if (cache)
87
+ return cache;
88
+ const attempt = Promise.resolve(typeof source === 'function' ? source() : source);
89
+ attempt.catch(() => { if (cache === attempt)
90
+ cache = undefined; });
91
+ return (cache = attempt);
92
+ };
93
+ }
@@ -1,10 +1,11 @@
1
1
  import { z } from 'zod';
2
+ import { stat } from 'node:fs/promises';
2
3
  import { McpToolError, minifiedResult } from '@chrischall/mcp-utils';
3
4
  import { withProgressHeartbeat } from './shared.js';
4
5
  import { base64ToBytes, bytesToBase64, formatMb, wholeMb } from '../bytes.js';
5
6
  import { fileNameFromUrl } from '../fetch-image.js';
6
7
  import { downloadFilename } from '../media-name.js';
7
- import { previewUnlessConfirmed, previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
8
+ import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
8
9
  /**
9
10
  * First-class Gemini Files API access.
10
11
  *
@@ -134,18 +135,34 @@ export function registerFileTools(server, client) {
134
135
  ? 'Provide exactly one of `url`, `data_base64`' + (onDisk ? ' or `path`.' : ' or `r2_key`.')
135
136
  : `Provide exactly one source, not ${sources.length} (${sources.join(', ')}).`);
136
137
  }
138
+ let localUpload;
137
139
  if (args.path) {
138
140
  if (!onDisk) {
139
141
  throw new McpToolError('`path` is unavailable on the hosted connector: it runs on a remote machine with no access to your filesystem. ' +
140
142
  'Use `url` (the server downloads it), `r2_key` after PUTting the file to a URL from gemini_get_upload_url, or ' +
141
143
  '`data_base64` if you cannot make HTTP requests at all.', { hint: 'Use `url`, `r2_key` (gemini_get_upload_url), or `data_base64`.' });
142
144
  }
145
+ // Resolve and identify BEFORE the gate, so the preview shows the MIME
146
+ // that will actually be sent. An unidentifiable file is refused rather
147
+ // than labelled image/png — the old image-only sniffer's default, which
148
+ // shipped every video, audio clip, GIF and HEIC mislabelled (#117).
149
+ const { resolveImagePath, detectUploadMime } = await import('../images.js');
150
+ const resolved = resolveImagePath(args.path);
151
+ const mimeType = args.mime_type ?? (await detectUploadMime(resolved));
152
+ if (!mimeType) {
153
+ throw new McpToolError(`Cannot tell what kind of file ${resolved} is from its bytes or extension — pass \`mime_type\` ` +
154
+ '(e.g. video/mp4, audio/mp3, image/gif) so it is not uploaded under the wrong type.', { hint: 'Pass mime_type explicitly.' });
155
+ }
143
156
  // Same gate as every other local-file input: a prompt-injected `path`
144
157
  // would otherwise ship an arbitrary local file to Google, and an
145
158
  // uploaded file is readable by anyone holding the api key for ~48h.
146
- const gate = await previewLocalInputsUnlessConfirmed(args.confirm, 'Upload a local file to the Gemini Files API', '/upload/v1beta/files', [args.path]);
147
- if (gate)
148
- return gate;
159
+ if (args.confirm !== true) {
160
+ const { size } = await stat(resolved);
161
+ return previewUnlessConfirmed(args.confirm, 'Upload a local file to the Gemini Files API', 'POST', '/upload/v1beta/files', {
162
+ inputs: [{ path: resolved, mimeType, size }],
163
+ });
164
+ }
165
+ localUpload = { path: resolved, mimeType };
149
166
  }
150
167
  const uploaded = await withProgressHeartbeat(extra, 'Uploading to the Gemini Files API', async () => {
151
168
  if (args.url) {
@@ -172,11 +189,9 @@ export function registerFileTools(server, client) {
172
189
  const bytes = base64ToBytes(decoded.base64);
173
190
  return client.uploadBytes(bytes, args.mime_type ?? decoded.mimeType, args.display_name ?? 'upload');
174
191
  }
175
- const { readImageAsInline, resolveImagePath } = await import('../images.js');
176
- const resolved = resolveImagePath(args.path);
177
- const inline = await readImageAsInline(resolved);
178
- const bytes = base64ToBytes(inline.base64);
179
- return client.uploadBytes(bytes, args.mime_type ?? inline.mimeType, args.display_name ?? (resolved.split(/[\\/]/).pop() || 'upload'));
192
+ // Streamed from disk (fileBlob), never buffered and re-encoded.
193
+ const local = localUpload;
194
+ return client.uploadFile(local.path, local.mimeType, args.display_name ?? (local.path.split(/[\\/]/).pop() || 'upload'));
180
195
  });
181
196
  return uploadResult(uploaded);
182
197
  });
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.1.3'; // x-release-please-version
2
+ export const VERSION = '2.1.4'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/gemini-mcp",
3
- "version": "2.1.3",
3
+ "version": "2.1.4",
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": "2.1.3",
9
+ "version": "2.1.4",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "2.1.3",
14
+ "version": "2.1.4",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },