@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bundle.js +138 -30
- package/dist/client.js +22 -7
- package/dist/images.js +52 -2
- package/dist/library.js +3 -2
- package/dist/storage/media.js +3 -2
- package/dist/tenant.js +93 -0
- package/dist/tools/files.js +24 -9
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -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.
|
|
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.
|
|
18
|
+
"version": "2.1.4",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
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
|
|
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.
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
63842
|
-
|
|
63843
|
-
|
|
63844
|
-
|
|
63845
|
-
|
|
63846
|
-
|
|
63847
|
-
|
|
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
|
|
63877
|
-
return client2.uploadBytes(
|
|
63878
|
-
}
|
|
63879
|
-
const
|
|
63880
|
-
|
|
63881
|
-
|
|
63882
|
-
|
|
63883
|
-
|
|
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
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
//
|
|
1166
|
-
|
|
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
|
|
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
|
-
|
|
46
|
-
const tenantId = (
|
|
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").`);
|
package/dist/storage/media.js
CHANGED
|
@@ -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
|
-
|
|
145
|
-
const tenantId = (
|
|
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
|
+
}
|
package/dist/tools/files.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
return
|
|
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
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
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.
|
|
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
|
+
"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.
|
|
9
|
+
"version": "2.1.4",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/gemini-mcp",
|
|
14
|
-
"version": "2.1.
|
|
14
|
+
"version": "2.1.4",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|