@chrischall/gemini-mcp 1.0.3 → 1.1.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/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;
@@ -0,0 +1,339 @@
1
+ import { McpToolError } from '@chrischall/mcp-utils';
2
+ /**
3
+ * Server-side fetching of `images_url` references.
4
+ *
5
+ * The point of this module is that image BYTES never travel through the model's
6
+ * context window. A caller hands over an `https://` URL — a few dozen tokens —
7
+ * and the *server* pulls the bytes down and turns them into an `inline_data`
8
+ * part or a Files API upload. The alternative, `images_base64`, costs roughly
9
+ * 14k tokens for a modest JPEG and is silently corrupted whenever a host
10
+ * truncates the read that produced it.
11
+ *
12
+ * Fetching a caller-supplied URL from inside the server is an SSRF primitive,
13
+ * and on the stdio build the server runs on the user's own machine, next to
14
+ * their LAN. So this is deliberately narrow:
15
+ *
16
+ * - **https only.** `http:`, `file:`, `data:`, `gopher:` and friends are out.
17
+ * - **No private/loopback/link-local destinations** — including the cloud
18
+ * metadata address, the classic escalation target.
19
+ * - **Redirects are followed MANUALLY**, revalidating every hop. `redirect:
20
+ * 'follow'` would let a public URL bounce to `http://169.254.169.254/` with
21
+ * no further check, which makes the origin check theatre.
22
+ * - **A hard byte cap enforced while streaming**, not from `Content-Length` —
23
+ * a hostile or merely wrong server can under-report or omit it.
24
+ *
25
+ * Module scope stays side-effect-free: `src/worker.ts` reaches this file
26
+ * through the tool registrars, so anything executed here would run during
27
+ * Cloudflare isolate startup.
28
+ */
29
+ /** Hard ceiling on a fetched image. Above this the URL is rejected outright. */
30
+ export const IMAGE_URL_MAX_BYTES = 15 * 1024 * 1024;
31
+ /**
32
+ * Above this an image is uploaded to the Files API and referenced by uri
33
+ * instead of inlined. `generateContent` caps a whole request at ~20 MB, so a
34
+ * couple of large inline references would fail the request itself — and a
35
+ * by-reference part costs nothing to repeat across calls.
36
+ */
37
+ export const IMAGE_INLINE_MAX_BYTES = 6 * 1024 * 1024;
38
+ /** Redirect hops followed before giving up. */
39
+ const MAX_REDIRECTS = 5;
40
+ /**
41
+ * Per-hop abort budget. Matches the client's default upstream timeout so a URL
42
+ * fetch is bounded like every other network call this server makes; callers
43
+ * override it with the same `timeout_ms` resolution (see `resolveTimeoutMs`).
44
+ */
45
+ const DEFAULT_FETCH_TIMEOUT_MS = 60_000;
46
+ /** Parse an IPv4 literal into its four octets, or `undefined` if it isn't one. */
47
+ function ipv4Octets(host) {
48
+ const parts = host.split('.');
49
+ if (parts.length !== 4)
50
+ return undefined;
51
+ const octets = parts.map((p) => (/^\d{1,3}$/.test(p) ? Number(p) : NaN));
52
+ return octets.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) ? octets : undefined;
53
+ }
54
+ /** True for addresses that are not on the public internet. */
55
+ function isPrivateIpv4([a, b]) {
56
+ if (a === 0 || a === 127)
57
+ return true; // this-host / loopback
58
+ if (a === 10)
59
+ return true; // RFC1918
60
+ if (a === 172 && b >= 16 && b <= 31)
61
+ return true; // RFC1918
62
+ if (a === 192 && b === 168)
63
+ return true; // RFC1918
64
+ if (a === 169 && b === 254)
65
+ return true; // link-local — includes 169.254.169.254
66
+ if (a === 100 && b >= 64 && b <= 127)
67
+ return true; // CGNAT
68
+ if (a === 192 && b === 0)
69
+ return true; // IETF protocol assignments
70
+ if (a === 198 && (b === 18 || b === 19))
71
+ return true; // benchmarking
72
+ if (a >= 224)
73
+ return true; // multicast + reserved
74
+ return false;
75
+ }
76
+ /**
77
+ * Expand an IPv6 literal into its eight 16-bit groups, or `undefined` if it
78
+ * isn't one. Handles `::` compression and a trailing dotted-quad
79
+ * (`::ffff:127.0.0.1`) — the latter never comes out of `new URL`, which
80
+ * re-serializes it as hex, but a redirect `Location` is parsed before
81
+ * normalization and a human writes it that way.
82
+ */
83
+ function ipv6Groups(host) {
84
+ if (!host.includes(':'))
85
+ return undefined;
86
+ let text = host;
87
+ const tail = [];
88
+ // A trailing dotted-quad occupies the last two groups.
89
+ const dotted = /:((?:\d{1,3}\.){3}\d{1,3})$/.exec(text);
90
+ if (dotted) {
91
+ const octets = ipv4Octets(dotted[1]);
92
+ if (!octets)
93
+ return undefined;
94
+ tail.push((octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]);
95
+ text = text.slice(0, dotted.index);
96
+ }
97
+ const halves = text.split('::');
98
+ if (halves.length > 2)
99
+ return undefined;
100
+ const parse = (part) => {
101
+ if (part === '')
102
+ return [];
103
+ const out = [];
104
+ for (const piece of part.split(':')) {
105
+ if (!/^[0-9a-f]{1,4}$/.test(piece))
106
+ return undefined;
107
+ out.push(parseInt(piece, 16));
108
+ }
109
+ return out;
110
+ };
111
+ const head = parse(halves[0]);
112
+ const rest = halves.length === 2 ? parse(halves[1]) : [];
113
+ if (!head || !rest)
114
+ return undefined;
115
+ const explicit = [...head, ...rest, ...tail];
116
+ if (halves.length === 2) {
117
+ if (explicit.length > 7)
118
+ return undefined;
119
+ return [...head, ...Array(8 - explicit.length).fill(0), ...rest, ...tail];
120
+ }
121
+ return explicit.length === 8 ? explicit : undefined;
122
+ }
123
+ /**
124
+ * The /96 prefixes that carry an IPv4 address in their final 32 bits, as the
125
+ * first six groups. A literal matching any of them is judged on the v4 address
126
+ * it embeds, not on the prefix.
127
+ *
128
+ * `::ffff:0:0/96` is the one that matters operationally — stacks really do
129
+ * resolve `[::ffff:7f00:1]` to loopback, and `new URL` normalizes
130
+ * `[::ffff:127.0.0.1]` into exactly that hex spelling, which is how a guard
131
+ * that pattern-matched the dotted form came to never fire.
132
+ *
133
+ * The rest are defense in depth: `::/96` is deprecated IPv4-compatible,
134
+ * `::ffff:0:0:0/96` is deprecated SIIT (RFC 2765), and `64:ff9b::/96` is the
135
+ * NAT64 well-known prefix (RFC 6052), which needs a translator on the network
136
+ * to route at all — and whose §3.1 already forbids embedding non-global v4.
137
+ * They are listed anyway because a function that understands one v4-carrying
138
+ * prefix and not its siblings is precisely the shape the original bypass had.
139
+ */
140
+ const V4_BEARING_PREFIXES = [
141
+ [0, 0, 0, 0, 0, 0xffff], // ::ffff:0:0/96 — IPv4-mapped
142
+ [0, 0, 0, 0, 0, 0], // ::/96 — IPv4-compatible (deprecated)
143
+ [0, 0, 0, 0, 0xffff, 0], // ::ffff:0:0:0/96 — IPv4-translated (deprecated SIIT)
144
+ [0x64, 0xff9b, 0, 0, 0, 0], // 64:ff9b::/96 — NAT64 well-known (RFC 6052)
145
+ ];
146
+ /**
147
+ * True for IPv6 addresses that are not on the public internet.
148
+ *
149
+ * Anything carrying an IPv4 address (see {@link V4_BEARING_PREFIXES}) is
150
+ * decided by running that address through the same {@link isPrivateIpv4} table
151
+ * as a bare v4 literal — so the two guards can never disagree about whether
152
+ * 10.0.0.5 is private.
153
+ */
154
+ function isPrivateIpv6(groups) {
155
+ if (groups.every((g) => g === 0))
156
+ return true; // ::
157
+ if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1)
158
+ return true; // ::1
159
+ if ((groups[0] & 0xffc0) === 0xfe80)
160
+ return true; // fe80::/10 link-local
161
+ if ((groups[0] & 0xfe00) === 0xfc00)
162
+ return true; // fc00::/7 unique-local
163
+ if (V4_BEARING_PREFIXES.some((prefix) => prefix.every((g, i) => groups[i] === g))) {
164
+ return isPrivateIpv4([groups[6] >> 8, groups[6] & 0xff, groups[7] >> 8, groups[7] & 0xff]);
165
+ }
166
+ return false;
167
+ }
168
+ /**
169
+ * Reject a URL that isn't a plain public https endpoint. Hostname-based, so it
170
+ * cannot catch a public name that RESOLVES to a private address (DNS rebinding)
171
+ * — closing that needs resolve-then-pin, which neither Node's nor workerd's
172
+ * fetch exposes. It does close the direct-literal and redirect-to-metadata
173
+ * cases, which is what a prompt-injected URL actually reaches for.
174
+ */
175
+ function assertPublicHttpsUrl(raw, requested) {
176
+ let url;
177
+ try {
178
+ url = new URL(raw);
179
+ }
180
+ catch {
181
+ throw new McpToolError(`Not a valid URL: ${raw}`, {
182
+ hint: 'images_url entries must be absolute https:// URLs.',
183
+ });
184
+ }
185
+ if (url.protocol !== 'https:') {
186
+ throw new McpToolError(`Refusing to fetch ${describeUrl(raw, requested)}: only https:// URLs are fetched (got "${url.protocol}").`, {
187
+ hint: 'Use an https:// URL, or send the bytes with images_base64 / gemini_upload_file.',
188
+ });
189
+ }
190
+ // `new URL` always brackets an IPv6 hostname, which is the reliable signal
191
+ // that this is a literal and not a name.
192
+ const rawHost = url.hostname.toLowerCase();
193
+ const isIpv6Literal = rawHost.startsWith('[') && rawHost.endsWith(']');
194
+ const host = isIpv6Literal ? rawHost.slice(1, -1) : rawHost;
195
+ const v4 = isIpv6Literal ? undefined : ipv4Octets(host);
196
+ const v6 = isIpv6Literal ? ipv6Groups(host) : undefined;
197
+ const blocked = isIpv6Literal
198
+ // Fail CLOSED on an IPv6 literal we cannot parse: a guard that allows what
199
+ // it does not understand is the wrong default for this control.
200
+ ? (v6 === undefined || isPrivateIpv6(v6))
201
+ : host === 'localhost' ||
202
+ host.endsWith('.localhost') ||
203
+ host.endsWith('.local') ||
204
+ (v4 ? isPrivateIpv4(v4) : false);
205
+ if (blocked) {
206
+ throw new McpToolError(`Refusing to fetch ${describeUrl(raw, requested)}: "${url.hostname}" is a private, loopback or link-local address, not a public host.`, { hint: 'images_url is fetched by the server, so it must point at a publicly reachable https:// image.' });
207
+ }
208
+ return url;
209
+ }
210
+ /** "<url>" — or "<url> (redirected from <requested>)" when they differ. */
211
+ function describeUrl(current, requested) {
212
+ return current === requested ? current : `${current} (redirected from ${requested})`;
213
+ }
214
+ /** Read a response body, aborting as soon as it exceeds `maxBytes`. */
215
+ async function readCapped(res, maxBytes, url, requested) {
216
+ const declared = Number(res.headers.get('content-length'));
217
+ if (Number.isFinite(declared) && declared > maxBytes) {
218
+ throw tooBig(url, requested, declared, maxBytes);
219
+ }
220
+ const reader = res.body?.getReader();
221
+ if (!reader) {
222
+ // No streaming body (some mocked/edge responses) — fall back to buffering,
223
+ // then apply the same cap. Correct, just less eager.
224
+ const buf = new Uint8Array(await res.arrayBuffer());
225
+ if (buf.byteLength > maxBytes)
226
+ throw tooBig(url, requested, buf.byteLength, maxBytes);
227
+ return buf;
228
+ }
229
+ const chunks = [];
230
+ let total = 0;
231
+ try {
232
+ for (;;) {
233
+ const { done, value } = await reader.read();
234
+ if (done)
235
+ break;
236
+ if (!value)
237
+ continue;
238
+ total += value.byteLength;
239
+ if (total > maxBytes) {
240
+ await reader.cancel().catch(() => { });
241
+ throw tooBig(url, requested, total, maxBytes, true);
242
+ }
243
+ chunks.push(value);
244
+ }
245
+ }
246
+ finally {
247
+ reader.releaseLock?.();
248
+ }
249
+ const out = new Uint8Array(total);
250
+ let offset = 0;
251
+ for (const c of chunks) {
252
+ out.set(c, offset);
253
+ offset += c.byteLength;
254
+ }
255
+ return out;
256
+ }
257
+ /** A transport-level failure, told apart from a plain unreachable host. */
258
+ function fetchFailure(err, current, requested, timeoutMs) {
259
+ const name = err?.name;
260
+ if (name === 'TimeoutError' || name === 'AbortError') {
261
+ return new McpToolError(`Fetching ${describeUrl(current, requested)} timed out after ${timeoutMs}ms.`, {
262
+ hint: 'The server accepted the connection but did not finish sending the image. Try a different URL, or raise timeout_ms.',
263
+ });
264
+ }
265
+ return new McpToolError(`Could not fetch ${describeUrl(current, requested)}: ${err instanceof Error ? err.message : String(err)}`, { hint: 'Check the URL is reachable from the server and serves the image without authentication.' });
266
+ }
267
+ function tooBig(url, requested, size, max, partial = false) {
268
+ const mb = (n) => `${(n / (1024 * 1024)).toFixed(1)} MB`;
269
+ return new McpToolError(`Image at ${describeUrl(url, requested)} is ${partial ? 'over' : ''} ${mb(size)}, above the ${mb(max)} limit for images_url.`, { hint: 'Resize or re-encode the image, or upload it yourself with gemini_upload_file and pass the returned file_uri.' });
270
+ }
271
+ /**
272
+ * Fetch one image URL server-side, validating protocol, destination, response
273
+ * status, content type and size. Every failure names the offending URL in the
274
+ * MESSAGE — MCP error serialization drops `hint`, and "one of your URLs failed"
275
+ * is useless when a call carried four of them.
276
+ */
277
+ export async function fetchRemoteImage(requestedUrl, opts = {}) {
278
+ const doFetch = opts.fetchImpl ?? fetch;
279
+ const maxBytes = opts.maxBytes ?? IMAGE_URL_MAX_BYTES;
280
+ const accept = opts.accept ?? ['image/'];
281
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
282
+ let current = requestedUrl;
283
+ for (let hop = 0;; hop++) {
284
+ const url = assertPublicHttpsUrl(current, requestedUrl);
285
+ // One budget per hop, applied to the body read as well: aborting the signal
286
+ // errors the response stream, so a trickling server cannot outlast it.
287
+ const signal = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined;
288
+ let res;
289
+ try {
290
+ // Manual redirects: every hop goes back through assertPublicHttpsUrl, so
291
+ // a public URL cannot bounce the fetch onto the metadata service.
292
+ res = await doFetch(url.toString(), {
293
+ redirect: 'manual',
294
+ headers: { accept: accept.map((p) => `${p.replace(/\/$/, '')}/*`).join(', ') },
295
+ ...(signal ? { signal } : {}),
296
+ });
297
+ }
298
+ catch (err) {
299
+ throw fetchFailure(err, current, requestedUrl, timeoutMs);
300
+ }
301
+ if (res.status >= 300 && res.status < 400) {
302
+ const location = res.headers.get('location');
303
+ if (!location) {
304
+ throw new McpToolError(`Fetching ${describeUrl(current, requestedUrl)} returned HTTP ${res.status} with no Location header.`);
305
+ }
306
+ if (hop >= MAX_REDIRECTS) {
307
+ throw new McpToolError(`Fetching ${requestedUrl} exceeded ${MAX_REDIRECTS} redirects (last hop: ${location}).`);
308
+ }
309
+ current = new URL(location, url).toString();
310
+ continue;
311
+ }
312
+ if (!res.ok) {
313
+ throw new McpToolError(`Fetching ${describeUrl(current, requestedUrl)} returned HTTP ${res.status} ${res.statusText || ''}`.trim(), {
314
+ hint: 'The URL must be publicly readable — a login page or signed-URL expiry shows up here as 401/403/404.',
315
+ });
316
+ }
317
+ const contentType = (res.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
318
+ if (!accept.some((prefix) => contentType.startsWith(prefix))) {
319
+ const expected = accept.map((p) => `${p.replace(/\/$/, '')}/*`).join(' or ');
320
+ throw new McpToolError(`${describeUrl(current, requestedUrl)} is not ${accept.length === 1 && accept[0] === 'image/' ? 'an image' : 'usable media'}: ` +
321
+ `the server returned Content-Type "${contentType || '(none)'}", expected ${expected}.`, { hint: 'Link directly to the media file, not to the page that displays it.' });
322
+ }
323
+ let bytes;
324
+ try {
325
+ bytes = await readCapped(res, maxBytes, current, requestedUrl);
326
+ }
327
+ catch (err) {
328
+ // The cap is its own actionable error; anything else here is a transport
329
+ // failure mid-body, most usefully reported as one (including the timeout).
330
+ if (err instanceof McpToolError)
331
+ throw err;
332
+ throw fetchFailure(err, current, requestedUrl, timeoutMs);
333
+ }
334
+ if (bytes.byteLength === 0) {
335
+ throw new McpToolError(`${describeUrl(current, requestedUrl)} returned an empty body.`);
336
+ }
337
+ return { bytes, mimeType: contentType, size: bytes.byteLength, finalUrl: current, requestedUrl };
338
+ }
339
+ }
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { registerInteractTools } from './tools/interact.js';
9
9
  import { registerVideoTools } from './tools/video.js';
10
10
  import { registerMusicTools } from './tools/music.js';
11
11
  import { registerJobTools } from './tools/jobs.js';
12
+ import { registerFileTools } from './tools/files.js';
12
13
  import { client } from './client.js';
13
14
  // Load `.env` here rather than in client.ts, which the Cloudflare Worker
14
15
  // imports: `import.meta.url` + async I/O at module scope crashes the isolate at
@@ -30,6 +31,6 @@ await runMcp({
30
31
  name: 'gemini-mcp',
31
32
  version: VERSION,
32
33
  banner: '[gemini-mcp] This project was developed and is maintained by AI (Claude). Use at your own discretion.',
33
- tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerVideoTools, registerMusicTools, registerJobTools],
34
+ tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerVideoTools, registerMusicTools, registerJobTools, registerFileTools],
34
35
  deps: client,
35
36
  });