@chrischall/gemini-mcp 1.0.4 → 1.2.0

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/bytes.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * base64 ↔ bytes, and byte-size formatting.
3
+ *
4
+ * These conversions had grown four near-identical copies — an inline
5
+ * `Uint8Array.from(atob(x), c => c.charCodeAt(0))` in `inputs.ts` and twice in
6
+ * `tools/files.ts`, plus a private `decodeBase64` in `storage/media.ts` — and
7
+ * a fifth (`encodeBase64`) that was subtly different because it is the only one
8
+ * that has to chunk. One place, so the chunking rule can't be forgotten in copy
9
+ * number six.
10
+ *
11
+ * `atob`/`btoa` rather than `Buffer`: this module is on the Worker's import
12
+ * graph, and they are the primitive both runtimes share. Module scope stays
13
+ * side-effect-free for the same reason (see the note in client.ts).
14
+ */
15
+ /** Decode base64 into raw bytes. */
16
+ export function base64ToBytes(base64) {
17
+ const binary = atob(base64);
18
+ const bytes = new Uint8Array(binary.length);
19
+ for (let i = 0; i < binary.length; i++)
20
+ bytes[i] = binary.charCodeAt(i);
21
+ return bytes;
22
+ }
23
+ /**
24
+ * Encode raw bytes as base64.
25
+ *
26
+ * Chunked because `String.fromCharCode(...bytes)` spreads one argument per
27
+ * byte and blows the engine's argument limit somewhere around 100–125k — i.e.
28
+ * it works fine on every small test fixture and throws on the first real
29
+ * photo, which is the worst possible place to learn about it.
30
+ */
31
+ export function bytesToBase64(bytes) {
32
+ const CHUNK = 0x8000;
33
+ let binary = '';
34
+ for (let i = 0; i < bytes.length; i += CHUNK) {
35
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
36
+ }
37
+ return btoa(binary);
38
+ }
39
+ /** Whole megabytes, for limits stated in tool descriptions (`25MB`). */
40
+ export function wholeMb(bytes) {
41
+ return Math.round(bytes / (1024 * 1024));
42
+ }
43
+ /** One-decimal megabytes, for error messages about an actual size (`15.4 MB`). */
44
+ export function formatMb(bytes) {
45
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
46
+ }
package/dist/client.js CHANGED
@@ -3,6 +3,7 @@ import { readEnvVar, McpToolError, ApiError, createApiClient, formatApiError, fi
3
3
  import { resolveModel, filterImageModels, DEFAULT_VIDEO_MODEL, DEFAULT_MUSIC_MODEL } from './models.js';
4
4
  import { createDiskSink } from './storage/media.js';
5
5
  import { SessionState } from './session.js';
6
+ import { fetchRemoteImage } from './fetch-image.js';
6
7
  // NOTE: this module must stay SIDE-EFFECT-FREE at module scope — no I/O, no
7
8
  // top-level await, no `import.meta.url`. `src/worker.ts` imports it, so every
8
9
  // line here runs during Cloudflare isolate startup, where global-scope I/O is
@@ -96,6 +97,37 @@ export class ChainedRequest404Error extends McpToolError {
96
97
  this.retries = retries;
97
98
  }
98
99
  }
100
+ /** `generateContent` part for one reference image (by uri, else inline). */
101
+ function generatePart(img) {
102
+ return img.uri
103
+ ? { file_data: { file_uri: img.uri, mime_type: img.mimeType } }
104
+ : { inline_data: { mime_type: img.mimeType, data: img.base64 } };
105
+ }
106
+ /** Interactions `input` part for one reference image (by uri, else inline). */
107
+ function interactPart(img) {
108
+ return img.uri
109
+ ? { type: 'image', uri: img.uri, mime_type: img.mimeType }
110
+ : { type: 'image', mime_type: img.mimeType, data: img.base64 };
111
+ }
112
+ /**
113
+ * Reduce any accepted spelling of a Files API reference to the bare resource
114
+ * name `files/<id>`.
115
+ *
116
+ * Callers hand these back and forth all day — a result echoes the full
117
+ * `https://…/v1beta/files/<id>` uri, docs and error messages say `files/<id>` —
118
+ * so both must work. The name is interpolated into a URL path, which is why the
119
+ * shape is asserted rather than assumed (same posture as the model-id guard).
120
+ */
121
+ export function normalizeFileName(ref) {
122
+ const trimmed = ref.trim();
123
+ const match = /(?:^|\/)(files\/[\w-]+)\/?$/.exec(trimmed);
124
+ if (!match) {
125
+ throw new McpToolError(`Not a Gemini Files API reference: "${ref}"`, {
126
+ hint: 'Pass the `files/<id>` name (or the full https://…/v1beta/files/<id> uri) returned by gemini_upload_file / POST /upload.',
127
+ });
128
+ }
129
+ return match[1];
130
+ }
99
131
  export class GeminiClient {
100
132
  /**
101
133
  * A key handed in explicitly (the connector's per-session key). When absent
@@ -184,6 +216,17 @@ export class GeminiClient {
184
216
  }
185
217
  return key;
186
218
  }
219
+ /**
220
+ * Fetch one `images_url` reference server-side. Routed through the client so
221
+ * it uses the SAME injected `fetchImpl` as every upstream call — which is
222
+ * what makes it mockable in tests and correct on the Worker.
223
+ */
224
+ async fetchRemoteImage(url, opts = {}) {
225
+ // Same timeout resolution as every other upstream call (per-call
226
+ // `timeout_ms` → $GEMINI_TIMEOUT_MS → 60s), so a URL fetch cannot be the
227
+ // one unbounded network operation in the server.
228
+ return fetchRemoteImage(url, { ...opts, fetchImpl: this.fetchImpl, timeoutMs: resolveTimeoutMs(opts.timeoutMs) });
229
+ }
187
230
  /** The default model after env override (no per-call arg). */
188
231
  defaultModel() {
189
232
  return resolveModel(undefined, readEnvVar('GEMINI_IMAGE_MODEL'));
@@ -215,8 +258,67 @@ export class GeminiClient {
215
258
  * (enforced locally before any bytes are sent).
216
259
  */
217
260
  async uploadVideo(path, mimeType) {
218
- const key = this.requireKey();
219
261
  const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: 'Video' });
262
+ return this.uploadToFilesApi(blob, mimeType, basename(path), blob.size);
263
+ }
264
+ /**
265
+ * Upload already-in-memory bytes (a fetched `images_url`, a `data_base64`
266
+ * payload, an inline `gemini_upload_file` body) to the Files API. Same three
267
+ * steps as {@link uploadVideo}, minus the filesystem — which is what makes it
268
+ * usable on the hosted connector, where there is no disk to stream from.
269
+ */
270
+ async uploadBytes(bytes, mimeType, displayName) {
271
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
272
+ if (view.byteLength > FILE_MAX_BYTES) {
273
+ throw new McpToolError(`File is ${view.byteLength} bytes, over the Gemini Files API limit of ${FILE_MAX_BYTES} bytes (2 GB).`);
274
+ }
275
+ // `new Blob([view])` already copies exactly the VIEW's bytes — its byte
276
+ // offset and length, not the whole backing buffer — so an extra `.slice()`
277
+ // here bought nothing and doubled peak memory, which matters most on the
278
+ // Worker's 128MB isolate where the fetched buffer is still live.
279
+ // The cast is a TS lib artefact: `BlobPart` is declared over
280
+ // `ArrayBuffer`-backed views, while a `Uint8Array` is generic in its buffer
281
+ // type. The runtime accepts any view.
282
+ const body = new Blob([view], { type: mimeType });
283
+ return this.uploadToFilesApi(body, mimeType, displayName, view.byteLength);
284
+ }
285
+ /**
286
+ * Upload a request body straight through to the Files API without buffering
287
+ * it — the direct-upload HTTP endpoint (`POST /upload` on the Worker) hands
288
+ * us a `ReadableStream` and a `Content-Length`, and the whole point of that
289
+ * endpoint is that the bytes never materialize anywhere they'd cost context
290
+ * or memory.
291
+ *
292
+ * `contentLength` is REQUIRED because the resumable protocol's start step
293
+ * declares it up front (`X-Goog-Upload-Header-Content-Length`); a stream of
294
+ * unknown length has to be buffered by the caller first.
295
+ */
296
+ async uploadStream(stream, mimeType, displayName, contentLength) {
297
+ if (contentLength > FILE_MAX_BYTES) {
298
+ throw new McpToolError(`File is ${contentLength} bytes, over the Gemini Files API limit of ${FILE_MAX_BYTES} bytes (2 GB).`);
299
+ }
300
+ return this.uploadToFilesApi(stream, mimeType, displayName, contentLength);
301
+ }
302
+ /**
303
+ * The resumable upload itself. Three live-verified steps (docs/GEMINI-API.md
304
+ * "Files API — local video upload"):
305
+ *
306
+ * 1. `POST {UPLOAD_BASE_URL}/files` (command `start`) → the upload session
307
+ * URL arrives in the `x-goog-upload-url` RESPONSE HEADER (body is empty).
308
+ * 2. `POST <session url>` (command `upload, finalize`) with the bytes.
309
+ * Response: `{file:…}`.
310
+ * 3. Poll `GET /v1beta/files/<id>` while `state` is `PROCESSING`, until
311
+ * `ACTIVE` (the poll response is the File object UNWRAPPED). Images are
312
+ * usually `ACTIVE` immediately; video is what processes.
313
+ *
314
+ * These two upload calls are raw `fetchImpl` (not `createApiClient`): step 1
315
+ * needs the response *header* and step 2 posts a binary body — neither fits
316
+ * `fetchJson`. The poll DOES go through the shared client (timeout + retry).
317
+ * Uploads deliberately have no abort timeout: a multi-hundred-MB file can
318
+ * legitimately take longer than any fixed budget.
319
+ */
320
+ async uploadToFilesApi(body, mimeType, displayName, contentLength) {
321
+ const key = this.requireKey();
220
322
  // 1. start — establish the resumable upload session
221
323
  const startPath = '/files';
222
324
  const startRes = await this.fetchImpl(`${UPLOAD_BASE_URL}${startPath}`, {
@@ -225,11 +327,11 @@ export class GeminiClient {
225
327
  'x-goog-api-key': key,
226
328
  'X-Goog-Upload-Protocol': 'resumable',
227
329
  'X-Goog-Upload-Command': 'start',
228
- 'X-Goog-Upload-Header-Content-Length': String(blob.size),
330
+ 'X-Goog-Upload-Header-Content-Length': String(contentLength),
229
331
  'X-Goog-Upload-Header-Content-Type': mimeType,
230
332
  'Content-Type': 'application/json',
231
333
  },
232
- body: JSON.stringify({ file: { display_name: basename(path) } }),
334
+ body: JSON.stringify({ file: { display_name: displayName } }),
233
335
  });
234
336
  if (!startRes.ok) {
235
337
  throw new McpToolError(formatApiError(startRes.status, 'POST', `/upload/v1beta${startPath}`, await startRes.text(), { service: SERVICE }));
@@ -242,13 +344,23 @@ export class GeminiClient {
242
344
  }
243
345
  // 2. upload + finalize — single shot; the session URL is self-authorizing
244
346
  // (no api-key header needed; verified live).
347
+ // A Blob body is left EXACTLY as the live-verified video upload sent it:
348
+ // fetch derives Content-Length from the blob, and setting the header by
349
+ // hand is the kind of change that breaks a verified path for no gain. A
350
+ // stream has no inherent length, so it declares one — otherwise the runtime
351
+ // falls back to chunked encoding, which the resumable protocol does not
352
+ // accept (this half is docs-derived; see docs/GEMINI-API.md).
353
+ const streaming = !(body instanceof Blob);
245
354
  const upRes = await this.fetchImpl(uploadUrl, {
246
355
  method: 'POST',
247
356
  headers: {
248
357
  'X-Goog-Upload-Offset': '0',
249
358
  'X-Goog-Upload-Command': 'upload, finalize',
359
+ ...(streaming ? { 'Content-Length': String(contentLength) } : {}),
250
360
  },
251
- body: blob,
361
+ body: body,
362
+ // Required by undici/workerd to send a ReadableStream body at all.
363
+ ...(streaming ? { duplex: 'half' } : {}),
252
364
  });
253
365
  if (!upRes.ok) {
254
366
  throw new McpToolError(formatApiError(upRes.status, 'POST', '/upload/v1beta/files (finalize)', await upRes.text(), { service: SERVICE }));
@@ -261,23 +373,90 @@ export class GeminiClient {
261
373
  if (!/^files\/[\w-]+$/.test(name)) {
262
374
  throw new McpToolError(`${SERVICE} upload returned an unexpected file name: ${name || '(none)'}`);
263
375
  }
264
- // 3. poll PROCESSING → ACTIVE
376
+ // 3. poll PROCESSING → ACTIVE. Images are normally ACTIVE on arrival; it is
377
+ // video that spends time here.
265
378
  for (let attempt = 0; file.state === 'PROCESSING'; attempt++) {
266
379
  if (attempt >= FILE_POLL_MAX_ATTEMPTS) {
267
- throw new McpToolError(`${SERVICE} video file ${name} is still processing after ${(FILE_POLL_INTERVAL_MS * FILE_POLL_MAX_ATTEMPTS) / 60_000} minutes`, {
268
- hint: 'Very long videos can take a while to process — retry later by passing the file uri as video_url.',
380
+ throw new McpToolError(`${SERVICE} file ${name} is still processing after ${(FILE_POLL_INTERVAL_MS * FILE_POLL_MAX_ATTEMPTS) / 60_000} minutes`, {
381
+ hint: 'Very long videos can take a while to process — retry later by passing the file uri as video_url / images_file_uris.',
269
382
  });
270
383
  }
271
384
  await this.sleep(FILE_POLL_INTERVAL_MS);
272
385
  file = await this.call('GET', `/${name}`);
273
386
  }
274
387
  if (file.state !== 'ACTIVE') {
275
- throw new McpToolError(`${SERVICE} video file processing failed (state ${file.state ?? 'unknown'})${file.error?.message ? `: ${file.error.message}` : ''}`, { hint: 'Check the video is a supported format/codec and under 2 GB.' });
388
+ throw new McpToolError(`${SERVICE} file processing failed (state ${file.state ?? 'unknown'})${file.error?.message ? `: ${file.error.message}` : ''}`, { hint: 'Check the file is a supported format/codec and under 2 GB.' });
276
389
  }
277
390
  if (!file.uri) {
278
391
  throw new McpToolError(`${SERVICE} did not return a uri for uploaded file ${name}`);
279
392
  }
280
- return { name, uri: file.uri, mimeType: file.mimeType ?? mimeType, expirationTime: file.expirationTime };
393
+ return {
394
+ name,
395
+ uri: file.uri,
396
+ mimeType: file.mimeType ?? mimeType,
397
+ expirationTime: file.expirationTime,
398
+ sizeBytes: file.sizeBytes,
399
+ displayName: file.displayName,
400
+ };
401
+ }
402
+ /**
403
+ * List the files this API key currently has in the Files API. Retention is
404
+ * ~48h, so the list is naturally short-lived — an empty list after a day is
405
+ * expiry, not a bug.
406
+ */
407
+ async listFiles(pageSize = 100) {
408
+ const size = Math.min(Math.max(Math.trunc(pageSize), 1), 100);
409
+ const data = await this.call('GET', `/files?pageSize=${size}`);
410
+ return (data.files ?? [])
411
+ .filter((f) => !!f.name && !!f.uri)
412
+ .map((f) => ({
413
+ name: f.name,
414
+ uri: f.uri,
415
+ mimeType: f.mimeType ?? 'application/octet-stream',
416
+ state: f.state,
417
+ createTime: f.createTime,
418
+ expirationTime: f.expirationTime,
419
+ sizeBytes: f.sizeBytes,
420
+ displayName: f.displayName,
421
+ }));
422
+ }
423
+ /**
424
+ * Fetch one file's metadata. Used to resolve a caller-supplied
425
+ * `images_file_uris` entry to its real MIME type — and, as a side effect, to
426
+ * fail fast with a clear "that file is gone" instead of the Interactions
427
+ * API's generic 404 after a billable request was already built.
428
+ */
429
+ async getFile(ref) {
430
+ const resource = normalizeFileName(ref);
431
+ const file = await this.call('GET', `/${resource}`);
432
+ if (!file.uri) {
433
+ throw new McpToolError(`${SERVICE} returned no uri for ${resource}`, {
434
+ hint: 'Files expire ~48h after upload — re-upload with gemini_upload_file (or POST /upload) and use the new uri.',
435
+ });
436
+ }
437
+ if (file.state && file.state !== 'ACTIVE') {
438
+ throw new McpToolError(`${SERVICE} file ${resource} is not usable yet (state ${file.state}).`, {
439
+ hint: 'Wait for the upload to finish processing, then retry.',
440
+ });
441
+ }
442
+ return {
443
+ name: file.name ?? resource,
444
+ uri: file.uri,
445
+ mimeType: file.mimeType ?? 'application/octet-stream',
446
+ expirationTime: file.expirationTime,
447
+ sizeBytes: file.sizeBytes,
448
+ displayName: file.displayName,
449
+ };
450
+ }
451
+ /**
452
+ * Delete one uploaded file. `name` is interpolated into the URL path, so it
453
+ * is validated to a bare `files/<id>` for the same reason the model id is
454
+ * (see {@link uploadToFilesApi}); a full `https://…/v1beta/files/<id>` uri is
455
+ * accepted and reduced to that form, since that is what every result echoes.
456
+ */
457
+ async deleteFile(name) {
458
+ const resource = normalizeFileName(name);
459
+ await this.call('DELETE', `/${resource}`);
281
460
  }
282
461
  async listModels() {
283
462
  const data = await this.call('GET', '/models?pageSize=200');
@@ -286,9 +465,8 @@ export class GeminiClient {
286
465
  async generate(opts) {
287
466
  const model = resolveModel(opts.model, readEnvVar('GEMINI_IMAGE_MODEL'));
288
467
  const parts = [{ text: opts.prompt }];
289
- for (const img of opts.images ?? []) {
290
- parts.push({ inline_data: { mime_type: img.mimeType, data: img.base64 } });
291
- }
468
+ for (const img of opts.images ?? [])
469
+ parts.push(generatePart(img));
292
470
  if (opts.videoUrl) {
293
471
  parts.push({ file_data: { file_uri: opts.videoUrl, mime_type: opts.videoMimeType ?? 'video/mp4' } });
294
472
  }
@@ -349,9 +527,8 @@ export class GeminiClient {
349
527
  async interact(opts) {
350
528
  const model = resolveModel(opts.model, readEnvVar('GEMINI_IMAGE_MODEL'));
351
529
  const inputParts = [{ type: 'text', text: opts.input }];
352
- for (const img of opts.images ?? []) {
353
- inputParts.push({ type: 'image', mime_type: img.mimeType, data: img.base64 });
354
- }
530
+ for (const img of opts.images ?? [])
531
+ inputParts.push(interactPart(img));
355
532
  if (opts.videoUrl) {
356
533
  inputParts.push({ type: 'video', uri: opts.videoUrl, mime_type: opts.videoMimeType ?? 'video/mp4' });
357
534
  }
@@ -390,7 +567,7 @@ export class GeminiClient {
390
567
  const model = opts.model ?? DEFAULT_VIDEO_MODEL;
391
568
  const inputParts = [{ type: 'text', text: opts.input }];
392
569
  for (const img of opts.images ?? [])
393
- inputParts.push({ type: 'image', mime_type: img.mimeType, data: img.base64 });
570
+ inputParts.push(interactPart(img));
394
571
  const responseFormat = { type: 'video', delivery: 'inline' };
395
572
  if (opts.aspectRatio)
396
573
  responseFormat.aspect_ratio = opts.aspectRatio;
@@ -416,7 +593,7 @@ export class GeminiClient {
416
593
  const model = opts.model ?? DEFAULT_MUSIC_MODEL;
417
594
  const inputParts = [{ type: 'text', text: opts.input }];
418
595
  for (const img of opts.images ?? [])
419
- inputParts.push({ type: 'image', mime_type: img.mimeType, data: img.base64 });
596
+ inputParts.push(interactPart(img));
420
597
  const responseFormat = { type: 'audio' };
421
598
  if (opts.audioFormat)
422
599
  responseFormat.audio_format = opts.audioFormat;