@chrischall/gemini-mcp 1.0.4 → 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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +74 -1
- package/dist/bundle.js +766 -55
- package/dist/client.js +194 -17
- package/dist/fetch-image.js +339 -0
- package/dist/index.js +2 -1
- package/dist/inputs.js +186 -0
- package/dist/session.js +39 -0
- package/dist/tools/files.js +194 -0
- package/dist/tools/generate.js +21 -12
- package/dist/tools/interact.js +11 -4
- package/dist/tools/set.js +29 -7
- package/dist/tools/shared.js +43 -6
- package/dist/upload-endpoint.js +79 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/gemini-mcp/SKILL.md +60 -4
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(
|
|
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:
|
|
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:
|
|
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}
|
|
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}
|
|
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 {
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
});
|