@chrischall/gemini-mcp 2.1.2 → 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/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.2'; // 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.2",
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>",
@@ -46,10 +46,10 @@
46
46
  "typecheck": "tsc -p tsconfig.test.json"
47
47
  },
48
48
  "dependencies": {
49
- "@chrischall/mcp-utils": "^1.0.0",
49
+ "@chrischall/mcp-utils": "^2.4.0",
50
50
  "@modelcontextprotocol/server": "^2.0.0",
51
51
  "dotenv": "^17.4.0",
52
- "zod": "^4.6.2"
52
+ "zod": "^4.6.5"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@modelcontextprotocol/client": "^2.0.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.1.2",
9
+ "version": "2.1.4",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "2.1.2",
14
+ "version": "2.1.4",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },