@chrischall/gemini-mcp 2.3.2 → 2.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +5 -3
- package/dist/bundle.js +753 -186
- package/dist/client.js +146 -39
- package/dist/dotenv.js +3 -4
- package/dist/fetch-image.js +10 -4
- package/dist/images.js +15 -5
- package/dist/index.js +6 -4
- package/dist/inputs.js +1 -1
- package/dist/library.js +9 -4
- package/dist/session.js +21 -15
- package/dist/sidecar.js +31 -7
- package/dist/tenant.js +4 -9
- package/dist/tools/_confirm.js +9 -0
- package/dist/tools/files.js +3 -1
- package/dist/tools/generate.js +2 -2
- package/dist/tools/interact.js +6 -6
- package/dist/tools/jobs.js +2 -2
- package/dist/tools/library.js +4 -2
- package/dist/tools/models.js +2 -2
- package/dist/tools/music.js +2 -2
- package/dist/tools/set.js +1 -1
- package/dist/tools/shared.js +1 -1
- package/dist/tools/usage.js +1 -1
- package/dist/tools/video.js +5 -7
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/server.json +38 -3
- package/skills/gemini-mcp/SKILL.md +3 -3
package/dist/client.js
CHANGED
|
@@ -12,12 +12,12 @@ import { SessionState } from './session.js';
|
|
|
12
12
|
import { fetchRemoteImage, readCapped, MAX_REDIRECTS } from './fetch-image.js';
|
|
13
13
|
import { bytesToBase64 } from './bytes.js';
|
|
14
14
|
import { explainOutsideUploadDir, uploadRoots } from './images.js';
|
|
15
|
-
// NOTE: this module
|
|
16
|
-
// top-level await, no `import.meta.url
|
|
17
|
-
//
|
|
18
|
-
// forbidden
|
|
19
|
-
//
|
|
20
|
-
// imported only by `src/index.ts`.
|
|
15
|
+
// NOTE: this module stays SIDE-EFFECT-FREE at module scope — no I/O, no
|
|
16
|
+
// top-level await, no `import.meta.url` — so importing it (tests, registrars)
|
|
17
|
+
// never boots anything. That rule dates from the retired Cloudflare Worker
|
|
18
|
+
// entry point, where global-scope I/O was forbidden; it is kept because it is
|
|
19
|
+
// what makes the module cheap to import. The stdio `.env` bootstrap lives in
|
|
20
|
+
// `src/dotenv.ts`, imported only by `src/index.ts`.
|
|
21
21
|
const BASE_URL = 'https://generativelanguage.googleapis.com/v1beta'; // v1 lacks gemini-3-pro-image; confirmed via Task 5
|
|
22
22
|
const SERVICE = 'Gemini';
|
|
23
23
|
const INTERACTIONS_SERVICE = 'Gemini Interactions';
|
|
@@ -44,6 +44,42 @@ export function resolveTimeoutMs(perCallMs, imageSize) {
|
|
|
44
44
|
// upload", verified 2026-06-12). The resumable upload endpoint lives under
|
|
45
45
|
// `/upload/v1beta`, NOT the normal `/v1beta` base.
|
|
46
46
|
const UPLOAD_BASE_URL = 'https://generativelanguage.googleapis.com/upload/v1beta';
|
|
47
|
+
/** True when `raw` is an https URL on googleapis.com (or a subdomain) — the
|
|
48
|
+
* only place a resumable-upload session URL may send the user's file. */
|
|
49
|
+
function isGoogleUploadUrl(raw) {
|
|
50
|
+
let u;
|
|
51
|
+
try {
|
|
52
|
+
u = new URL(raw);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
return u.protocol === 'https:' && (u.hostname === 'googleapis.com' || u.hostname.endsWith('.googleapis.com'));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Slowest sustained throughput a media transfer is allowed before it is
|
|
61
|
+
* treated as stalled. A transfer's deadline is the normal request budget plus
|
|
62
|
+
* the time its size takes at this rate, so a large file on a slow link still
|
|
63
|
+
* finishes while a connection that stops sending cannot hang the tool call
|
|
64
|
+
* forever (chrischall/fleet-audit#467) — the progress heartbeat around these
|
|
65
|
+
* calls actively stops the host from timing them out for us.
|
|
66
|
+
*/
|
|
67
|
+
const MIN_TRANSFER_BYTES_PER_SEC = 256 * 1024;
|
|
68
|
+
/** Deadline for moving `bytes` up or down; see {@link MIN_TRANSFER_BYTES_PER_SEC}. */
|
|
69
|
+
function transferTimeoutMs(bytes) {
|
|
70
|
+
return resolveTimeoutMs() + Math.ceil((bytes / MIN_TRANSFER_BYTES_PER_SEC) * 1000);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* An abort signal that fires after `ms`. A plain timer rather than
|
|
74
|
+
* `AbortSignal.timeout` so it can be cleared once the transfer is done (no
|
|
75
|
+
* stray timer keeping a process alive) and driven by fake timers in tests.
|
|
76
|
+
*/
|
|
77
|
+
function transferDeadline(ms) {
|
|
78
|
+
const ctrl = new AbortController();
|
|
79
|
+
const timer = setTimeout(() => ctrl.abort(new DOMException(`timed out after ${ms}ms`, 'TimeoutError')), ms);
|
|
80
|
+
timer.unref?.();
|
|
81
|
+
return { signal: ctrl.signal, clear: () => clearTimeout(timer) };
|
|
82
|
+
}
|
|
47
83
|
// Documented Files API per-file cap (2 GB); enforced locally so a too-big video
|
|
48
84
|
// fails fast instead of uploading gigabytes just to be rejected.
|
|
49
85
|
const FILE_MAX_BYTES = 2 * 1024 ** 3;
|
|
@@ -201,10 +237,9 @@ export class GeminiClient {
|
|
|
201
237
|
mediaSink;
|
|
202
238
|
/**
|
|
203
239
|
* This client's session memory — job registry, last-interaction id, written
|
|
204
|
-
* outputs. One client is one session
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
* server is single-user, so its one singleton client is one session.
|
|
240
|
+
* outputs. One client is one session. Production builds one client per
|
|
241
|
+
* process and one process is one user, so this is process-wide — see the
|
|
242
|
+
* invariant (and what a multi-user host would need) in src/session.ts.
|
|
208
243
|
*/
|
|
209
244
|
session = new SessionState();
|
|
210
245
|
/** Per-account character/style library — hosted connector only (see options). */
|
|
@@ -359,8 +394,9 @@ export class GeminiClient {
|
|
|
359
394
|
* These two upload calls are raw `fetchImpl` (not `createApiClient`): step 1
|
|
360
395
|
* needs the response *header* and step 2 posts a binary body — neither fits
|
|
361
396
|
* `fetchJson`. The poll DOES go through the shared client (timeout + retry).
|
|
362
|
-
*
|
|
363
|
-
* legitimately take longer than any fixed budget
|
|
397
|
+
* The deadline scales with the file (see `transferTimeoutMs`): a
|
|
398
|
+
* multi-hundred-MB video can legitimately take longer than any fixed budget,
|
|
399
|
+
* but a stalled upload must still fail rather than hang.
|
|
364
400
|
*
|
|
365
401
|
* Returned files live ~48h (`expirationTime`); per-file cap is 2 GB
|
|
366
402
|
* (enforced locally before any bytes are sent).
|
|
@@ -427,9 +463,11 @@ export class GeminiClient {
|
|
|
427
463
|
* These two upload calls are raw `fetchImpl` (not `createApiClient`): step 1
|
|
428
464
|
* needs the response *header* and step 2 posts a binary body — neither fits
|
|
429
465
|
* `fetchJson`. The poll DOES go through the shared client (timeout + retry).
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
466
|
+
* By default the two round trips get a deadline sized to the file (see
|
|
467
|
+
* `transferTimeoutMs`) — a multi-hundred-MB file can legitimately take longer
|
|
468
|
+
* than any fixed budget, but a stalled one must fail rather than hang. A
|
|
469
|
+
* caller that is uploading OPPORTUNISTICALLY passes its own, shorter
|
|
470
|
+
* `signal` instead (see `uploadBytes`).
|
|
433
471
|
*/
|
|
434
472
|
async uploadToFilesApi(body, mimeType, displayName, contentLength,
|
|
435
473
|
/** Bounds the two upload round trips. The PROCESSING poll below is left
|
|
@@ -437,6 +475,33 @@ export class GeminiClient {
|
|
|
437
475
|
* video rather than for the images this is used to bound. */
|
|
438
476
|
signal) {
|
|
439
477
|
const key = this.requireKey();
|
|
478
|
+
// A caller that uploads opportunistically brings its own (short) signal.
|
|
479
|
+
// Everyone else gets a deadline sized to the file, so a stalled upload
|
|
480
|
+
// fails instead of hanging the tool call (chrischall/fleet-audit#467).
|
|
481
|
+
// The deadline covers the two upload round trips ONLY and is cleared
|
|
482
|
+
// before the PROCESSING poll: the poll has its own attempt cap, and a
|
|
483
|
+
// processing failure (or a slow transcode) reached after the deadline must
|
|
484
|
+
// keep its own error rather than be relabelled a stalled upload.
|
|
485
|
+
const deadline = signal ? undefined : transferDeadline(transferTimeoutMs(contentLength));
|
|
486
|
+
let uploaded;
|
|
487
|
+
try {
|
|
488
|
+
uploaded = await this.sendToFilesApi(body, mimeType, displayName, contentLength, key, signal ?? deadline.signal);
|
|
489
|
+
}
|
|
490
|
+
catch (err) {
|
|
491
|
+
if (deadline?.signal.aborted) {
|
|
492
|
+
throw new McpToolError(`${SERVICE} Files API upload of ${displayName} timed out after ${transferTimeoutMs(contentLength)}ms`, {
|
|
493
|
+
hint: 'The upload stalled. Retry, or check the network between this server and generativelanguage.googleapis.com.',
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
throw err;
|
|
497
|
+
}
|
|
498
|
+
finally {
|
|
499
|
+
deadline?.clear();
|
|
500
|
+
}
|
|
501
|
+
return this.awaitFileActive(uploaded.name, uploaded.file, mimeType);
|
|
502
|
+
}
|
|
503
|
+
/** {@link uploadToFilesApi}'s steps 1 and 2 (start + upload/finalize), under `signal`. */
|
|
504
|
+
async sendToFilesApi(body, mimeType, displayName, contentLength, key, signal) {
|
|
440
505
|
// Local alias so the calls below carry NO receiver: `this.fetchImpl(...)`
|
|
441
506
|
// would hand native fetch this GeminiClient as `this`, which workerd
|
|
442
507
|
// rejects with an Illegal invocation (see the constructor note).
|
|
@@ -454,7 +519,7 @@ export class GeminiClient {
|
|
|
454
519
|
'Content-Type': 'application/json',
|
|
455
520
|
},
|
|
456
521
|
body: JSON.stringify({ file: { display_name: displayName } }),
|
|
457
|
-
|
|
522
|
+
signal,
|
|
458
523
|
});
|
|
459
524
|
if (!startRes.ok) {
|
|
460
525
|
throw new McpToolError(formatApiError(startRes.status, 'POST', `/upload/v1beta${startPath}`, await startRes.text(), { service: SERVICE }));
|
|
@@ -465,6 +530,14 @@ export class GeminiClient {
|
|
|
465
530
|
hint: 'The Files API resumable-upload contract may have changed — see docs/GEMINI-API.md.',
|
|
466
531
|
});
|
|
467
532
|
}
|
|
533
|
+
// The session URL is the destination of the user's file bytes, and it
|
|
534
|
+
// comes from a response header — so it must be a Google upload host over
|
|
535
|
+
// https before anything is posted to it (chrischall/fleet-audit#1011).
|
|
536
|
+
if (!isGoogleUploadUrl(uploadUrl)) {
|
|
537
|
+
throw new McpToolError(`${SERVICE} upload start returned a session URL outside googleapis.com; refusing to send the file`, {
|
|
538
|
+
hint: 'The Files API resumable-upload contract may have changed — see docs/GEMINI-API.md.',
|
|
539
|
+
});
|
|
540
|
+
}
|
|
468
541
|
// 2. upload + finalize — single shot; the session URL is self-authorizing
|
|
469
542
|
// (no api-key header needed; verified live).
|
|
470
543
|
// The Blob body is left EXACTLY as the live-verified video upload sent it:
|
|
@@ -479,7 +552,7 @@ export class GeminiClient {
|
|
|
479
552
|
'X-Goog-Upload-Command': 'upload, finalize',
|
|
480
553
|
},
|
|
481
554
|
body: body,
|
|
482
|
-
|
|
555
|
+
signal,
|
|
483
556
|
});
|
|
484
557
|
if (!upRes.ok) {
|
|
485
558
|
throw new McpToolError(formatApiError(upRes.status, 'POST', '/upload/v1beta/files (finalize)', await upRes.text(), { service: SERVICE }));
|
|
@@ -492,6 +565,10 @@ export class GeminiClient {
|
|
|
492
565
|
if (!/^files\/[\w-]+$/.test(name)) {
|
|
493
566
|
throw new McpToolError(`${SERVICE} upload returned an unexpected file name: ${name || '(none)'}`);
|
|
494
567
|
}
|
|
568
|
+
return { name, file };
|
|
569
|
+
}
|
|
570
|
+
/** {@link uploadToFilesApi}'s step 3: wait out PROCESSING. Outside the upload deadline. */
|
|
571
|
+
async awaitFileActive(name, file, mimeType) {
|
|
495
572
|
// 3. poll PROCESSING → ACTIVE. Images are normally ACTIVE on arrival; it is
|
|
496
573
|
// video that spends time here.
|
|
497
574
|
for (let attempt = 0; file.state === 'PROCESSING'; attempt++) {
|
|
@@ -1015,31 +1092,50 @@ export class GeminiClient {
|
|
|
1015
1092
|
// fetch-image.ts enforces, and stricter: there is exactly one host
|
|
1016
1093
|
// generated media is ever served from.
|
|
1017
1094
|
const url = assertMediaHost(current, requested);
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1095
|
+
// One deadline per hop, covering the body as well as the headers: a
|
|
1096
|
+
// server that answers and then stops sending would otherwise hang the
|
|
1097
|
+
// tool call forever (chrischall/fleet-audit#467).
|
|
1098
|
+
const budgetMs = transferTimeoutMs(MEDIA_DOWNLOAD_MAX_BYTES);
|
|
1099
|
+
const deadline = transferDeadline(budgetMs);
|
|
1100
|
+
try {
|
|
1101
|
+
const res = await doFetch(url.toString(), {
|
|
1102
|
+
redirect: 'manual',
|
|
1103
|
+
headers: { 'x-goog-api-key': key },
|
|
1104
|
+
signal: deadline.signal,
|
|
1105
|
+
});
|
|
1106
|
+
if (res.status >= 300 && res.status < 400) {
|
|
1107
|
+
const location = res.headers?.get('location');
|
|
1108
|
+
if (!location) {
|
|
1109
|
+
throw new McpToolError(`${SERVICE} media download returned HTTP ${res.status} with no Location header for ${current}`);
|
|
1110
|
+
}
|
|
1111
|
+
if (hop >= MAX_REDIRECTS) {
|
|
1112
|
+
throw new McpToolError(`${SERVICE} media download for ${requested} exceeded ${MAX_REDIRECTS} redirects (last hop: ${location}).`);
|
|
1113
|
+
}
|
|
1114
|
+
current = new URL(location, url).toString();
|
|
1115
|
+
continue;
|
|
1026
1116
|
}
|
|
1027
|
-
if (
|
|
1028
|
-
throw new McpToolError(`${SERVICE} media download
|
|
1117
|
+
if (!res.ok) {
|
|
1118
|
+
throw new McpToolError(`${SERVICE} media download failed (HTTP ${res.status}) for ${current}`, { hint: 'Generated files live in the Files API for ~48h and the download requires the api key that created them.' });
|
|
1029
1119
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1120
|
+
// Capped while streaming rather than buffered whole: a generated clip is
|
|
1121
|
+
// small, but `arrayBuffer()` on a surprise is an unbounded allocation.
|
|
1122
|
+
const bytes = await readCapped(res, MEDIA_DOWNLOAD_MAX_BYTES, current, requested, MEDIA_CAP_SUBJECT);
|
|
1123
|
+
return {
|
|
1124
|
+
base64: bytesToBase64(bytes),
|
|
1125
|
+
mimeType: media.mimeType || res.headers?.get('content-type') || 'application/octet-stream',
|
|
1126
|
+
};
|
|
1032
1127
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1128
|
+
catch (err) {
|
|
1129
|
+
if (deadline.signal.aborted && !(err instanceof McpToolError)) {
|
|
1130
|
+
throw new McpToolError(`${SERVICE} media download timed out after ${budgetMs}ms for ${current}`, {
|
|
1131
|
+
hint: 'The download stalled. Recover the result with gemini_get_result, or retry.',
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
throw err;
|
|
1135
|
+
}
|
|
1136
|
+
finally {
|
|
1137
|
+
deadline.clear();
|
|
1035
1138
|
}
|
|
1036
|
-
// Capped while streaming rather than buffered whole: a generated clip is
|
|
1037
|
-
// small, but `arrayBuffer()` on a surprise is an unbounded allocation.
|
|
1038
|
-
const bytes = await readCapped(res, MEDIA_DOWNLOAD_MAX_BYTES, current, requested, MEDIA_CAP_SUBJECT);
|
|
1039
|
-
return {
|
|
1040
|
-
base64: bytesToBase64(bytes),
|
|
1041
|
-
mimeType: media.mimeType || res.headers?.get('content-type') || 'application/octet-stream',
|
|
1042
|
-
};
|
|
1043
1139
|
}
|
|
1044
1140
|
}
|
|
1045
1141
|
/** {@link downloadMedia} over a list, preserving order. */
|
|
@@ -1185,7 +1281,18 @@ export function hostedStorage(blob = blobStoreFromEnv()) {
|
|
|
1185
1281
|
// pinned in the store on first use — derived from the current key so the
|
|
1186
1282
|
// data already there stays reachable — and never re-derived after that.
|
|
1187
1283
|
// Lazy because the key is read at request time (deferred-config-error).
|
|
1188
|
-
|
|
1284
|
+
// With no key configured yet the tenant is provisional: used for that call
|
|
1285
|
+
// but never pinned, or the permanent pin would lock the namespace to
|
|
1286
|
+
// sha('local') and orphan the key's data (chrischall/fleet-audit#1009).
|
|
1287
|
+
// Accepted boundary: the library and media sink memoise their tenant per
|
|
1288
|
+
// process (`tenantResolver`), so a process that starts keyless and has its
|
|
1289
|
+
// key set later WITHOUT a restart keeps using the provisional 'local'
|
|
1290
|
+
// namespace until it restarts. Nothing is pinned, so the restart recovers;
|
|
1291
|
+
// mcp-host sets env at spawn, so a key never arrives mid-process there.
|
|
1292
|
+
const tenant = pinnedTenant(blob.bucket, async () => {
|
|
1293
|
+
const key = readEnvVar('GEMINI_API_KEY');
|
|
1294
|
+
return key ? tenantIdFor(key) : { tenant: await tenantIdFor('local'), pin: false };
|
|
1295
|
+
});
|
|
1189
1296
|
return {
|
|
1190
1297
|
mediaSink: createR2Sink(blob.bucket, {
|
|
1191
1298
|
// The SAME `links` object the minter gets below. Media GETs and upload
|
package/dist/dotenv.js
CHANGED
|
@@ -5,14 +5,13 @@ import { loadDotenvSafely } from '@chrischall/mcp-utils';
|
|
|
5
5
|
* The stdio server's `.env` bootstrap. **Imported only by `src/index.ts`.**
|
|
6
6
|
*
|
|
7
7
|
* This used to run at module scope in `src/client.ts`, which put it in the
|
|
8
|
-
*
|
|
9
|
-
* it during Worker isolate startup. Two things
|
|
8
|
+
* import graph of the (since retired) Cloudflare Worker connector and so ran
|
|
9
|
+
* it during Worker isolate startup. Two things went wrong there:
|
|
10
10
|
*
|
|
11
11
|
* 1. wrangler's bundle leaves `import.meta.url` undefined, so
|
|
12
12
|
* `fileURLToPath(import.meta.url)` threw and the isolate never booted —
|
|
13
13
|
* `The Workers runtime failed to start`. Not a test failure: a deploy
|
|
14
|
-
* failure, invisible to the workers-pool suite
|
|
15
|
-
* `tests/connector-boot.test.ts` for why).
|
|
14
|
+
* failure, invisible to the workers-pool suite.
|
|
16
15
|
* 2. Even had it resolved, it is async I/O at global scope, which Workers
|
|
17
16
|
* forbids — and there is no `.env` on a Worker regardless. Config arrives
|
|
18
17
|
* through bindings.
|
package/dist/fetch-image.js
CHANGED
|
@@ -23,9 +23,8 @@ import { formatMb } from './bytes.js';
|
|
|
23
23
|
* - **A hard byte cap enforced while streaming**, not from `Content-Length` —
|
|
24
24
|
* a hostile or merely wrong server can under-report or omit it.
|
|
25
25
|
*
|
|
26
|
-
* Module scope stays side-effect-free
|
|
27
|
-
*
|
|
28
|
-
* Cloudflare isolate startup.
|
|
26
|
+
* Module scope stays side-effect-free (the same rule as client.ts): the tool
|
|
27
|
+
* registrars import this file, and importing must never do work.
|
|
29
28
|
*/
|
|
30
29
|
/** Hard ceiling on a fetched image. Above this the URL is rejected outright. */
|
|
31
30
|
export const IMAGE_URL_MAX_BYTES = 15 * 1024 * 1024;
|
|
@@ -192,7 +191,10 @@ function assertPublicHttpsUrl(raw, requested) {
|
|
|
192
191
|
// that this is a literal and not a name.
|
|
193
192
|
const rawHost = url.hostname.toLowerCase();
|
|
194
193
|
const isIpv6Literal = rawHost.startsWith('[') && rawHost.endsWith(']');
|
|
195
|
-
|
|
194
|
+
// A name may carry a trailing dot (the fully-qualified spelling): WHATWG URL
|
|
195
|
+
// keeps it, and `localhost.` still resolves to loopback, so the suffix
|
|
196
|
+
// checks below must see the name without it (chrischall/fleet-audit#472).
|
|
197
|
+
const host = isIpv6Literal ? rawHost.slice(1, -1) : rawHost.replace(/\.+$/, '');
|
|
196
198
|
const v4 = isIpv6Literal ? undefined : ipv4Octets(host);
|
|
197
199
|
const v6 = isIpv6Literal ? ipv6Groups(host) : undefined;
|
|
198
200
|
const blocked = isIpv6Literal
|
|
@@ -202,6 +204,10 @@ function assertPublicHttpsUrl(raw, requested) {
|
|
|
202
204
|
: host === 'localhost' ||
|
|
203
205
|
host.endsWith('.localhost') ||
|
|
204
206
|
host.endsWith('.local') ||
|
|
207
|
+
// Private-network names: Fly's 6PN (`<app>.internal`, where the hosted
|
|
208
|
+
// build runs) and GCP's metadata server (`metadata.google.internal`).
|
|
209
|
+
host === 'internal' ||
|
|
210
|
+
host.endsWith('.internal') ||
|
|
205
211
|
(v4 ? isPrivateIpv4(v4) : false);
|
|
206
212
|
if (blocked) {
|
|
207
213
|
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.' });
|
package/dist/images.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile, stat } from 'node:fs/promises';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
3
4
|
import { delimiter, join, resolve, isAbsolute } from 'node:path';
|
|
4
5
|
import { assertPathWithinRoots, expandPath, fileBlob, readEnvVar, McpToolError, readFileHead, resolveOutputDir as resolveSharedOutputDir, sniffMimeBytes as sniffSharedMime, writeFileSafe, writeUniqueFile, } from '@chrischall/mcp-utils';
|
|
5
6
|
/**
|
|
@@ -388,6 +389,12 @@ export function baseName(name) {
|
|
|
388
389
|
.replace(/^-+|-+$/g, '');
|
|
389
390
|
return safe || 'image';
|
|
390
391
|
}
|
|
392
|
+
/** The `~/Downloads/<name>` segment mcp-utils' `resolveOutputDir` defaults to. */
|
|
393
|
+
const OUTPUT_DIR_NAME = 'gemini-mcp';
|
|
394
|
+
/** Where output goes when neither a per-call dir nor GEMINI_OUTPUT_DIR is set. */
|
|
395
|
+
function defaultOutputDir() {
|
|
396
|
+
return join(homedir(), 'Downloads', OUTPUT_DIR_NAME);
|
|
397
|
+
}
|
|
391
398
|
/**
|
|
392
399
|
* The output directory to LOOK IN (sidecar lookups: `continue_last`'s disk
|
|
393
400
|
* fallback, the chain-404 re-anchor) — the same resolution and the same
|
|
@@ -400,17 +407,19 @@ export function lookupOutputDir(perCall) {
|
|
|
400
407
|
const configured = readEnvVar('GEMINI_OUTPUT_DIR');
|
|
401
408
|
const raw = trimmed ?? configured;
|
|
402
409
|
if (!raw)
|
|
403
|
-
return
|
|
410
|
+
return defaultOutputDir();
|
|
404
411
|
const dir = expandPath(raw);
|
|
405
412
|
if (trimmed && configured)
|
|
406
413
|
assertPathWithinRoots(dir, [configured]);
|
|
407
414
|
return dir;
|
|
408
415
|
}
|
|
409
416
|
/**
|
|
410
|
-
* per-call → $GEMINI_OUTPUT_DIR →
|
|
411
|
-
* relative paths expanded, the directory created;
|
|
412
|
-
* GEMINI_OUTPUT_DIR when that is set).
|
|
413
|
-
*
|
|
417
|
+
* per-call → $GEMINI_OUTPUT_DIR → ~/Downloads/gemini-mcp (mcp-utils
|
|
418
|
+
* `resolveOutputDir`: `~` and relative paths expanded, the directory created;
|
|
419
|
+
* a per-call dir confined to GEMINI_OUTPUT_DIR when that is set). Never the
|
|
420
|
+
* cwd — that is `/` under Claude Desktop and the user's repo under Claude
|
|
421
|
+
* Code. A blank per-call value counts as unset, so it falls through to the
|
|
422
|
+
* env var rather than to the default.
|
|
414
423
|
*/
|
|
415
424
|
export function resolveOutputDir(perCall) {
|
|
416
425
|
// output_dir is model-chosen: once the operator sets GEMINI_OUTPUT_DIR, a
|
|
@@ -418,6 +427,7 @@ export function resolveOutputDir(perCall) {
|
|
|
418
427
|
// keeps the old, unconfined behaviour (the fleet pattern, as in splitwise-mcp).
|
|
419
428
|
const configured = readEnvVar('GEMINI_OUTPUT_DIR');
|
|
420
429
|
return resolveSharedOutputDir(perCall?.trim() || undefined, 'GEMINI_OUTPUT_DIR', {
|
|
430
|
+
name: OUTPUT_DIR_NAME,
|
|
421
431
|
...(configured ? { allowedRoots: [configured] } : {}),
|
|
422
432
|
});
|
|
423
433
|
}
|
package/dist/index.js
CHANGED
|
@@ -13,13 +13,15 @@ import { client } from './client.js';
|
|
|
13
13
|
// singleton is constructed (by the import above) before this line runs.
|
|
14
14
|
await loadStdioDotenv();
|
|
15
15
|
// The registrars take their GeminiClient as an argument (they import only the
|
|
16
|
-
// *type*), so a
|
|
17
|
-
//
|
|
18
|
-
//
|
|
16
|
+
// *type*), so a future multi-user entry point COULD build one client per user.
|
|
17
|
+
// None does: this is the only entry point (mcp-host runs it as a per-registration
|
|
18
|
+
// child), and it threads the env-driven module-level singleton through runMcp's
|
|
19
|
+
// `deps` to every connection — sound because one registration is one user (see
|
|
20
|
+
// src/session.ts).
|
|
19
21
|
// That singleton defers its config error to the first request — so the server
|
|
20
22
|
// boots and answers the host's install-time tools/list probe even without
|
|
21
23
|
// GEMINI_API_KEY. It also owns this process's session state (job registry,
|
|
22
|
-
// last-interaction memory);
|
|
24
|
+
// last-interaction memory); one process is one user, so one client is one session.
|
|
23
25
|
//
|
|
24
26
|
// The roster itself lives in registrars.ts: this file cannot be imported
|
|
25
27
|
// without booting a server, and a list nothing can import is a list nothing
|
package/dist/inputs.js
CHANGED
|
@@ -14,7 +14,7 @@ export function hasImageInput(args) {
|
|
|
14
14
|
* How long the opportunistic base64 upload gets before it is abandoned.
|
|
15
15
|
*
|
|
16
16
|
* The promotion adds two round trips in front of a generation nobody asked to
|
|
17
|
-
* wait for, and `uploadToFilesApi` is
|
|
17
|
+
* wait for, and `uploadToFilesApi`'s own deadline is sized for big files. Past this the call
|
|
18
18
|
* proceeds inline: slower to repeat, but not stalled.
|
|
19
19
|
*/
|
|
20
20
|
const BASE64_UPLOAD_TIMEOUT_MS = 30_000;
|
package/dist/library.js
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
*
|
|
18
18
|
* Two properties are load-bearing:
|
|
19
19
|
*
|
|
20
|
-
* - **No expiry.** The retention
|
|
21
|
-
*
|
|
20
|
+
* - **No expiry.** The host's retention prune reclaims `gen/` and `up/` but
|
|
21
|
+
* never `lib/` (see blob-store.ts) — a saved character outlives
|
|
22
22
|
* every signed URL and every upload. That is why saving COPIES the image
|
|
23
23
|
* bytes into `lib/` rather than storing an `r2_key` pointer: a pointer into
|
|
24
24
|
* `gen/` or `up/` would dangle after the next sweep.
|
|
@@ -40,6 +40,8 @@ export const LIBRARY_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/;
|
|
|
40
40
|
export function isValidLibraryName(name) {
|
|
41
41
|
return LIBRARY_NAME_PATTERN.test(name);
|
|
42
42
|
}
|
|
43
|
+
/** Pages one library listing will walk: 20 x 1000 keys, far past any real library. */
|
|
44
|
+
const LIBRARY_MAX_LIST_PAGES = 20;
|
|
43
45
|
/** Build the R2-backed library for one session. */
|
|
44
46
|
export function createR2Library(bucket, opts) {
|
|
45
47
|
const now = opts.now ?? (() => new Date());
|
|
@@ -77,10 +79,13 @@ export function createR2Library(bucket, opts) {
|
|
|
77
79
|
const prefix = `${LIBRARY_KEY_PREFIX}/${await tenantId()}/${kind}/`;
|
|
78
80
|
const keys = [];
|
|
79
81
|
let cursor;
|
|
80
|
-
|
|
82
|
+
// Bounded, like the media walk in storage/media.ts: a store that reports
|
|
83
|
+
// `truncated` with no cursor would otherwise re-list page one forever
|
|
84
|
+
// (chrischall/fleet-audit#468), and a listing is unbounded work on a tool call.
|
|
85
|
+
for (let n = 0; n < LIBRARY_MAX_LIST_PAGES; n++) {
|
|
81
86
|
const page = await bucket.list({ prefix, cursor, limit: 1000 });
|
|
82
87
|
keys.push(...page.objects.map((o) => o.key).filter((k) => k.endsWith('.json')));
|
|
83
|
-
if (!page.truncated)
|
|
88
|
+
if (!page.truncated || !page.cursor)
|
|
84
89
|
break;
|
|
85
90
|
cursor = page.cursor;
|
|
86
91
|
}
|
package/dist/session.js
CHANGED
|
@@ -5,20 +5,25 @@ import { estimateCost } from './pricing.js';
|
|
|
5
5
|
* Everything the server remembers *between tool calls on behalf of one user*.
|
|
6
6
|
*
|
|
7
7
|
* All of this used to be module-level (`jobs`/`byKey`/`runningByFingerprint` in
|
|
8
|
-
* jobs.ts, `lastInteractionId`/`writtenOutputs` in tools/interact.ts). That
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* every authenticated claude.ai session on that child: the per-session
|
|
14
|
-
* `GeminiClient` isolated the API key but not the memory around it, so a
|
|
15
|
-
* colliding `idempotency_key` replayed another user's result, an identical
|
|
16
|
-
* prompt attached to another user's in-flight (billable) job, and
|
|
17
|
-
* `continue_last: true` resumed another user's interaction under your key.
|
|
8
|
+
* jobs.ts, `lastInteractionId`/`writtenOutputs` in tools/interact.ts). That was
|
|
9
|
+
* a cross-tenant leak on the retired Cloudflare Worker connector, which served
|
|
10
|
+
* many users from one isolate: a colliding `idempotency_key` replayed another
|
|
11
|
+
* user's result, an identical prompt attached to another user's in-flight
|
|
12
|
+
* (billable) job, and `continue_last: true` resumed another user's interaction.
|
|
18
13
|
*
|
|
19
|
-
* So session memory hangs off the `GeminiClient`
|
|
20
|
-
*
|
|
21
|
-
*
|
|
14
|
+
* So session memory hangs off the `GeminiClient` and is threaded into every
|
|
15
|
+
* registrar; handlers reach it as `client.session`.
|
|
16
|
+
*
|
|
17
|
+
* **The invariant production relies on today: one registration is one user.**
|
|
18
|
+
* Both entry points — stdio, and mcp-host, which runs this same stdio server as
|
|
19
|
+
* a supervised child per registration — build exactly ONE `GeminiClient` (the
|
|
20
|
+
* module-level `client` in client.ts, handed to every connection by `runMcp`
|
|
21
|
+
* in index.ts), so there is one process-wide `SessionState`, shared by every
|
|
22
|
+
* connection to that process. That is safe only because every one of those
|
|
23
|
+
* connections is the same user. If a host ever multiplexes several users onto
|
|
24
|
+
* one process, this is NOT enough on its own: it must also build a client per
|
|
25
|
+
* user (tests/session-isolation.test.ts shows that separate clients do not
|
|
26
|
+
* share state; nothing in production builds more than one today).
|
|
22
27
|
*
|
|
23
28
|
* The rule this encodes: **`src/` must hold no module-level mutable state.**
|
|
24
29
|
* If you need to remember something across calls, put it here.
|
|
@@ -92,8 +97,9 @@ export class SessionState {
|
|
|
92
97
|
* Accumulated in the CLIENT rather than in the tools, so a call that was
|
|
93
98
|
* billed and then failed (a safety filter returning no image, say) still
|
|
94
99
|
* counts — it cost money regardless of whether a tool could use the result.
|
|
95
|
-
*
|
|
96
|
-
*
|
|
100
|
+
* On the session, never module scope: a module-level total would report
|
|
101
|
+
* other users' spend as yours on any host that served several users from one
|
|
102
|
+
* process (see the invariant above).
|
|
97
103
|
*/
|
|
98
104
|
usageTotal;
|
|
99
105
|
/** How many upstream calls contributed to {@link usageTotal}. */
|
package/dist/sidecar.js
CHANGED
|
@@ -13,10 +13,24 @@
|
|
|
13
13
|
* sidecar for that id names the image file to re-attach, which is exactly
|
|
14
14
|
* the manual recovery the old error message asked the caller to perform.
|
|
15
15
|
*/
|
|
16
|
-
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
17
|
-
import {
|
|
18
|
-
import { join } from 'node:path';
|
|
16
|
+
import { readdir, readFile, stat, lstat } from 'node:fs/promises';
|
|
17
|
+
import { join, resolve } from 'node:path';
|
|
19
18
|
const SIDECAR_SUFFIX = '.json';
|
|
19
|
+
/**
|
|
20
|
+
* `writeSidecar` always names the sidecar after its output: `<file>.<ext>.json`.
|
|
21
|
+
* Any other `.json` in the directory — the output dir can be any
|
|
22
|
+
* operator- or model-chosen directory, such as a cloned repo — is not ours and is never read (chrischall/fleet-audit#473).
|
|
23
|
+
*/
|
|
24
|
+
const SIDECAR_NAME = /^.+\.[A-Za-z0-9]+\.json$/;
|
|
25
|
+
/** True for a regular file (NOT a symlink — the server never writes through one). */
|
|
26
|
+
async function isRegularFile(path) {
|
|
27
|
+
try {
|
|
28
|
+
return (await lstat(path)).isFile();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
20
34
|
/**
|
|
21
35
|
* All parseable sidecars in `dir`, newest first. Never throws: a missing dir,
|
|
22
36
|
* an unreadable file, malformed JSON, or a record with no `interaction_id` is
|
|
@@ -31,10 +45,15 @@ export async function readSidecars(dir) {
|
|
|
31
45
|
catch {
|
|
32
46
|
return [];
|
|
33
47
|
}
|
|
48
|
+
// The outputs this directory has sidecars for. A recorded image path is
|
|
49
|
+
// only trusted when it is one of these: an output file this server wrote,
|
|
50
|
+
// sitting in this directory beside its own sidecar. That is what keeps a
|
|
51
|
+
// planted sidecar from naming an arbitrary local file for the chain
|
|
52
|
+
// re-anchor to read and send upstream without the confirm gate.
|
|
53
|
+
const sidecarNames = entries.filter((e) => SIDECAR_NAME.test(e));
|
|
54
|
+
const outputs = new Set(sidecarNames.map((e) => resolve(dir, e.slice(0, -SIDECAR_SUFFIX.length))));
|
|
34
55
|
const records = [];
|
|
35
|
-
for (const entry of
|
|
36
|
-
if (!entry.endsWith(SIDECAR_SUFFIX))
|
|
37
|
-
continue;
|
|
56
|
+
for (const entry of sidecarNames) {
|
|
38
57
|
const sidecarPath = join(dir, entry);
|
|
39
58
|
try {
|
|
40
59
|
const parsed = JSON.parse(await readFile(sidecarPath, 'utf8'));
|
|
@@ -44,10 +63,15 @@ export async function readSidecars(dir) {
|
|
|
44
63
|
if (typeof interactionId !== 'string' || !interactionId)
|
|
45
64
|
continue;
|
|
46
65
|
const paths = Array.isArray(images) ? images.filter((p) => typeof p === 'string') : [];
|
|
66
|
+
const kept = [];
|
|
67
|
+
for (const p of paths) {
|
|
68
|
+
if (outputs.has(resolve(p)) && (await isRegularFile(p)))
|
|
69
|
+
kept.push(p);
|
|
70
|
+
}
|
|
47
71
|
records.push({
|
|
48
72
|
sidecarPath,
|
|
49
73
|
interactionId,
|
|
50
|
-
images:
|
|
74
|
+
images: kept,
|
|
51
75
|
mtimeMs: (await stat(sidecarPath)).mtimeMs,
|
|
52
76
|
});
|
|
53
77
|
}
|
package/dist/tenant.js
CHANGED
|
@@ -32,21 +32,16 @@ async function readPin(bucket) {
|
|
|
32
32
|
return undefined;
|
|
33
33
|
}
|
|
34
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
35
|
export function pinnedTenant(bucket, derive) {
|
|
44
36
|
let cached;
|
|
45
37
|
async function resolve() {
|
|
46
38
|
const pinned = await readPin(bucket);
|
|
47
39
|
if (pinned)
|
|
48
40
|
return { tenant: pinned, pinned: true };
|
|
49
|
-
const
|
|
41
|
+
const derived = await derive();
|
|
42
|
+
if (typeof derived !== 'string')
|
|
43
|
+
return { tenant: derived.tenant, pinned: false };
|
|
44
|
+
const tenant = derived;
|
|
50
45
|
try {
|
|
51
46
|
await bucket.put(TENANT_PIN_KEY, JSON.stringify({ tenant }), {
|
|
52
47
|
httpMetadata: { contentType: 'application/json' },
|
package/dist/tools/_confirm.js
CHANGED
|
@@ -27,10 +27,18 @@ export async function confirmWrite(ctx, o) {
|
|
|
27
27
|
message: o.message,
|
|
28
28
|
details: preview,
|
|
29
29
|
tool: o.tool,
|
|
30
|
+
// One API key per process (a hosted deployment runs one child per
|
|
31
|
+
// account), so there is no in-process principal to bind.
|
|
32
|
+
account: undefined,
|
|
33
|
+
args: withoutConfirmToken(o.args),
|
|
30
34
|
confirmToken: o.confirmToken,
|
|
31
35
|
subject: () => ({ target: o.target, payload: o.payload, preview }),
|
|
32
36
|
}));
|
|
33
37
|
}
|
|
38
|
+
function withoutConfirmToken(args) {
|
|
39
|
+
const { confirmToken: _omit, ...rest } = args;
|
|
40
|
+
return rest;
|
|
41
|
+
}
|
|
34
42
|
/**
|
|
35
43
|
* Confirm-gate for a Gemini tool that ships LOCAL input files (image/video
|
|
36
44
|
* paths) to Google. Proceeds straight away when the call has no local file
|
|
@@ -58,6 +66,7 @@ export async function confirmLocalInputs(ctx, o) {
|
|
|
58
66
|
body: { inputs },
|
|
59
67
|
target: '',
|
|
60
68
|
payload: { request: o.request, inputs },
|
|
69
|
+
args: o.request,
|
|
61
70
|
confirmToken: o.confirmToken,
|
|
62
71
|
});
|
|
63
72
|
}
|