@chrischall/gemini-mcp 2.3.4 → 2.4.1
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/.mcp.json +5 -2
- package/README.md +20 -17
- package/dist/bundle.js +31785 -312
- package/dist/client.js +12 -3
- package/dist/fetch-image.js +70 -138
- package/dist/images.js +16 -23
- package/dist/media-url.js +4 -59
- package/dist/models.js +11 -6
- package/dist/pricing.js +4 -0
- package/dist/storage/media.js +3 -15
- package/dist/tools/_confirm.js +13 -33
- package/dist/tools/files.js +8 -8
- package/dist/tools/interact.js +1 -1
- package/dist/tools/library.js +6 -6
- package/dist/tools/shared.js +7 -4
- package/dist/tools/uploads.js +1 -1
- package/dist/version.js +1 -1
- package/mint.yaml +19 -3
- package/package.json +4 -3
- package/server.json +4 -4
- package/skills/gemini-mcp/SKILL.md +37 -18
package/dist/client.js
CHANGED
|
@@ -227,6 +227,8 @@ export class GeminiClient {
|
|
|
227
227
|
explicitApiKey;
|
|
228
228
|
apis = new Map();
|
|
229
229
|
fetchImpl;
|
|
230
|
+
/** The fetch the caller injected (tests), if any — see fetchRemoteImage. */
|
|
231
|
+
injectedFetch;
|
|
230
232
|
sleep;
|
|
231
233
|
/**
|
|
232
234
|
* The media destination for every tool this client is handed to. Public and
|
|
@@ -274,6 +276,7 @@ export class GeminiClient {
|
|
|
274
276
|
// also why call sites must alias it to a local before calling (see
|
|
275
277
|
// uploadToFilesApi). Guarded by tests/tools/hosted-reference-forms.test.ts.
|
|
276
278
|
this.fetchImpl = opts.fetchImpl ?? ((...args) => fetch(...args));
|
|
279
|
+
this.injectedFetch = opts.fetchImpl;
|
|
277
280
|
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
278
281
|
}
|
|
279
282
|
/**
|
|
@@ -346,14 +349,20 @@ export class GeminiClient {
|
|
|
346
349
|
}
|
|
347
350
|
/**
|
|
348
351
|
* Fetch one `images_url` reference server-side. Routed through the client so
|
|
349
|
-
*
|
|
350
|
-
*
|
|
352
|
+
* tests that inject a `fetchImpl` mock this call too. Without an injected
|
|
353
|
+
* fetch it is NOT the client's default fetch: fetch-image.ts pins the
|
|
354
|
+
* connection to public addresses itself (mcp-utils netguard,
|
|
355
|
+
* fleet-audit#1252), which the plain global fetch cannot do.
|
|
351
356
|
*/
|
|
352
357
|
async fetchRemoteImage(url, opts = {}) {
|
|
353
358
|
// Same timeout resolution as every other upstream call (per-call
|
|
354
359
|
// `timeout_ms` → $GEMINI_TIMEOUT_MS → 60s), so a URL fetch cannot be the
|
|
355
360
|
// one unbounded network operation in the server.
|
|
356
|
-
return fetchRemoteImage(url, {
|
|
361
|
+
return fetchRemoteImage(url, {
|
|
362
|
+
...opts,
|
|
363
|
+
...(this.injectedFetch ? { fetchImpl: this.injectedFetch } : {}),
|
|
364
|
+
timeoutMs: resolveTimeoutMs(opts.timeoutMs),
|
|
365
|
+
});
|
|
357
366
|
}
|
|
358
367
|
/**
|
|
359
368
|
* Read back media this connector generated, straight out of its own store.
|
package/dist/fetch-image.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { McpToolError } from '@chrischall/mcp-utils';
|
|
1
|
+
import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
|
|
2
|
+
import { createPublicOnlyDispatcher, isPublicAddress } from '@chrischall/mcp-utils/netguard';
|
|
3
|
+
import { fetch as undiciFetch } from 'undici';
|
|
2
4
|
import { formatMb } from './bytes.js';
|
|
3
5
|
/**
|
|
4
6
|
* Server-side fetching of `images_url` references.
|
|
@@ -16,7 +18,16 @@ import { formatMb } from './bytes.js';
|
|
|
16
18
|
*
|
|
17
19
|
* - **https only.** `http:`, `file:`, `data:`, `gopher:` and friends are out.
|
|
18
20
|
* - **No private/loopback/link-local destinations** — including the cloud
|
|
19
|
-
* metadata address, the classic escalation target.
|
|
21
|
+
* metadata address, the classic escalation target. Literals and private
|
|
22
|
+
* names are refused up front (mcp-utils netguard `isPublicAddress`, the
|
|
23
|
+
* fleet's one address table), and on a local install every connection goes
|
|
24
|
+
* through netguard's public-only dispatcher, which resolves the name itself
|
|
25
|
+
* and pins the socket to the addresses it vetted — so a public name that
|
|
26
|
+
* resolves or REBINDS to a private address is refused at connect
|
|
27
|
+
* (fleet-audit#1252). Behind an egress proxy (`HTTPS_PROXY`, which mcp-host
|
|
28
|
+
* sets for its children) the global fetch is used instead: the child cannot
|
|
29
|
+
* resolve names there at all, and the proxy resolves and applies the same
|
|
30
|
+
* public-only table itself.
|
|
20
31
|
* - **Redirects are followed MANUALLY**, revalidating every hop. `redirect:
|
|
21
32
|
* 'follow'` would let a public URL bounce to `http://169.254.169.254/` with
|
|
22
33
|
* no further check, which makes the origin check theatre.
|
|
@@ -43,134 +54,12 @@ export const MAX_REDIRECTS = 5;
|
|
|
43
54
|
* override it with the same `timeout_ms` resolution (see `resolveTimeoutMs`).
|
|
44
55
|
*/
|
|
45
56
|
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
57
|
/**
|
|
169
|
-
* Reject a URL that isn't a plain public https endpoint
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
58
|
+
* Reject a URL that isn't a plain public https endpoint: a private name
|
|
59
|
+
* (`localhost`, `*.local`, `*.internal`) or a non-public IP literal, judged by
|
|
60
|
+
* mcp-utils netguard `isPublicAddress` (which fails closed on a literal it
|
|
61
|
+
* cannot parse). A public NAME that resolves inward is caught later, at
|
|
62
|
+
* connect, by the pinned dispatcher (see the module header).
|
|
174
63
|
*/
|
|
175
64
|
function assertPublicHttpsUrl(raw, requested) {
|
|
176
65
|
let url;
|
|
@@ -195,20 +84,18 @@ function assertPublicHttpsUrl(raw, requested) {
|
|
|
195
84
|
// keeps it, and `localhost.` still resolves to loopback, so the suffix
|
|
196
85
|
// checks below must see the name without it (chrischall/fleet-audit#472).
|
|
197
86
|
const host = isIpv6Literal ? rawHost.slice(1, -1) : rawHost.replace(/\.+$/, '');
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
? (v6 === undefined || isPrivateIpv6(v6))
|
|
87
|
+
const ipv4Literal = /^[0-9.]+$/.test(host);
|
|
88
|
+
const blocked = isIpv6Literal || ipv4Literal
|
|
89
|
+
// isPublicAddress fails CLOSED on a literal it cannot parse: a guard that
|
|
90
|
+
// allows what it does not understand is the wrong default for this control.
|
|
91
|
+
? !isPublicAddress(host)
|
|
204
92
|
: host === 'localhost' ||
|
|
205
93
|
host.endsWith('.localhost') ||
|
|
206
94
|
host.endsWith('.local') ||
|
|
207
95
|
// Private-network names: Fly's 6PN (`<app>.internal`, where the hosted
|
|
208
96
|
// build runs) and GCP's metadata server (`metadata.google.internal`).
|
|
209
97
|
host === 'internal' ||
|
|
210
|
-
host.endsWith('.internal')
|
|
211
|
-
(v4 ? isPrivateIpv4(v4) : false);
|
|
98
|
+
host.endsWith('.internal');
|
|
212
99
|
if (blocked) {
|
|
213
100
|
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.' });
|
|
214
101
|
}
|
|
@@ -285,6 +172,16 @@ export async function readCapped(res, maxBytes, url, requested, subject) {
|
|
|
285
172
|
}
|
|
286
173
|
/** A transport-level failure, told apart from a plain unreachable host. */
|
|
287
174
|
function fetchFailure(err, current, requested, timeoutMs) {
|
|
175
|
+
if (refusedAtConnect(err)) {
|
|
176
|
+
let host = current;
|
|
177
|
+
try {
|
|
178
|
+
host = new URL(current).hostname;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
/* keep the raw URL */
|
|
182
|
+
}
|
|
183
|
+
return new McpToolError(`Refusing to fetch ${describeUrl(current, requested)}: "${host}" resolves to 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.' });
|
|
184
|
+
}
|
|
288
185
|
const name = err?.name;
|
|
289
186
|
if (name === 'TimeoutError' || name === 'AbortError') {
|
|
290
187
|
return new McpToolError(`Fetching ${describeUrl(current, requested)} timed out after ${timeoutMs}ms.`, {
|
|
@@ -293,6 +190,24 @@ function fetchFailure(err, current, requested, timeoutMs) {
|
|
|
293
190
|
}
|
|
294
191
|
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.' });
|
|
295
192
|
}
|
|
193
|
+
/** True when netguard's pinned dispatcher refused the connection (anywhere in the cause chain). */
|
|
194
|
+
function refusedAtConnect(err) {
|
|
195
|
+
let e = err;
|
|
196
|
+
for (let i = 0; i < 8 && e; i += 1) {
|
|
197
|
+
if (e.name === 'UrlNotAllowedError')
|
|
198
|
+
return true;
|
|
199
|
+
e = e.cause;
|
|
200
|
+
}
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Is this process behind an egress proxy? mcp-host hands its children
|
|
205
|
+
* `HTTPS_PROXY` (+ `NODE_USE_ENV_PROXY=1`); the child cannot resolve names
|
|
206
|
+
* there, and the proxy applies the public-only table itself.
|
|
207
|
+
*/
|
|
208
|
+
function egressProxied() {
|
|
209
|
+
return Boolean(readEnvVar('HTTPS_PROXY') ?? readEnvVar('https_proxy'));
|
|
210
|
+
}
|
|
296
211
|
/**
|
|
297
212
|
* `subject` names what was too big and how to act on it. It defaults to the
|
|
298
213
|
* `images_url` wording because that is where the cap started, but the reader is
|
|
@@ -315,10 +230,27 @@ const IMAGE_CAP_SUBJECT = {
|
|
|
315
230
|
* is useless when a call carried four of them.
|
|
316
231
|
*/
|
|
317
232
|
export async function fetchRemoteImage(requestedUrl, opts = {}) {
|
|
318
|
-
const doFetch = opts.fetchImpl ?? fetch;
|
|
319
233
|
const maxBytes = opts.maxBytes ?? IMAGE_URL_MAX_BYTES;
|
|
320
234
|
const accept = opts.accept ?? ['image/'];
|
|
321
235
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
236
|
+
// An injected fetch (tests) is used as-is. Otherwise: pinned to public
|
|
237
|
+
// addresses on a local install; the global fetch behind an egress proxy.
|
|
238
|
+
const dispatcher = opts.fetchImpl === undefined && !egressProxied()
|
|
239
|
+
? createPublicOnlyDispatcher(opts.resolve ? { resolve: opts.resolve } : {})
|
|
240
|
+
: undefined;
|
|
241
|
+
const doFetch = opts.fetchImpl ??
|
|
242
|
+
(dispatcher
|
|
243
|
+
? ((input, init) => undiciFetch(input, { ...init, dispatcher }))
|
|
244
|
+
: (...args) => fetch(...args));
|
|
245
|
+
try {
|
|
246
|
+
return await fetchHops(requestedUrl, doFetch, { maxBytes, accept, timeoutMs });
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
await dispatcher?.destroy().catch(() => { });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** The redirect loop of {@link fetchRemoteImage}, over the chosen fetch. */
|
|
253
|
+
async function fetchHops(requestedUrl, doFetch, { maxBytes, accept, timeoutMs }) {
|
|
322
254
|
let current = requestedUrl;
|
|
323
255
|
for (let hop = 0;; hop++) {
|
|
324
256
|
const url = assertPublicHttpsUrl(current, requestedUrl);
|
package/dist/images.js
CHANGED
|
@@ -2,7 +2,7 @@ import { readFile, stat } from 'node:fs/promises';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { delimiter, join, resolve, isAbsolute } from 'node:path';
|
|
5
|
-
import { assertPathWithinRoots, expandPath, fileBlob, readEnvVar, McpToolError, readFileHead, resolveOutputDir as resolveSharedOutputDir, sniffMimeBytes as sniffSharedMime, writeFileSafe, writeUniqueFile, } from '@chrischall/mcp-utils';
|
|
5
|
+
import { extensionForMime, assertPathWithinRoots, expandPath, fileBlob, readEnvVar, McpToolError, readFileHead, resolveOutputDir as resolveSharedOutputDir, sniffMimeBytes as sniffSharedMime, writeFileSafe, writeUniqueFile, } from '@chrischall/mcp-utils';
|
|
6
6
|
/**
|
|
7
7
|
* `GEMINI_UPLOAD_DIR` — the optional allow-list for local files streamed to
|
|
8
8
|
* the Files API (`gemini_upload_file` `path`, `video_path`). One or more
|
|
@@ -127,29 +127,22 @@ function sniffImageMime(buf) {
|
|
|
127
127
|
const mime = sniffSharedMime(buf);
|
|
128
128
|
return mime !== undefined && REFERENCE_IMAGE_MIMES.has(mime) ? mime : undefined;
|
|
129
129
|
}
|
|
130
|
-
/**
|
|
131
|
-
*
|
|
130
|
+
/**
|
|
131
|
+
* Gemini MIME types the shared table does not know. Music can come back as
|
|
132
|
+
* `audio/ogg_opus`, whose subtype is not a plain word, so it would otherwise
|
|
133
|
+
* be named `.bin`.
|
|
134
|
+
*/
|
|
135
|
+
const GEMINI_MIME_EXT = { 'audio/ogg_opus': 'ogg' };
|
|
136
|
+
/**
|
|
137
|
+
* File extension for a media MIME (image/video/audio): mcp-utils
|
|
138
|
+
* `extensionForMime` (the fleet's one MIME table, fleet-audit#1301), plus
|
|
139
|
+
* {@link GEMINI_MIME_EXT}. The result always passes `isSafeExtension`, so a
|
|
140
|
+
* strange upstream Content-Type can never reach `writeUniqueFile` as an
|
|
141
|
+
* extension it refuses (an unknown type becomes `bin`).
|
|
142
|
+
*/
|
|
132
143
|
export function mediaExt(mimeType) {
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
return 'jpg';
|
|
136
|
-
if (m.includes('webp'))
|
|
137
|
-
return 'webp';
|
|
138
|
-
if (m.includes('png'))
|
|
139
|
-
return 'png';
|
|
140
|
-
if (m.includes('mp4'))
|
|
141
|
-
return 'mp4';
|
|
142
|
-
if (m.includes('quicktime'))
|
|
143
|
-
return 'mov';
|
|
144
|
-
if (m.includes('webm'))
|
|
145
|
-
return 'webm';
|
|
146
|
-
if (m.includes('wav'))
|
|
147
|
-
return 'wav';
|
|
148
|
-
if (m.includes('mpeg') || m.includes('mp3'))
|
|
149
|
-
return 'mp3';
|
|
150
|
-
if (m.includes('ogg'))
|
|
151
|
-
return 'ogg';
|
|
152
|
-
return m.split('/')[1]?.split(';')[0]?.replace(/^x-/, '') || 'bin';
|
|
144
|
+
const base = mimeType.split(';', 1)[0].trim().toLowerCase();
|
|
145
|
+
return GEMINI_MIME_EXT[base] ?? extensionForMime(mimeType);
|
|
153
146
|
}
|
|
154
147
|
/** Decode base64 media bytes (image/video/audio) and write to disk (creating
|
|
155
148
|
* dir), picking the extension from the MIME. Returns the absolute path.
|
package/dist/media-url.js
CHANGED
|
@@ -18,8 +18,6 @@
|
|
|
18
18
|
import { signedLinks } from './signed-url.js';
|
|
19
19
|
/** How long a freshly minted media URL stays valid. Matches Files API retention. */
|
|
20
20
|
export const MEDIA_URL_TTL_MS = 48 * 60 * 60 * 1000;
|
|
21
|
-
/** KV key holding the lazily-generated signing secret (zero-config path). */
|
|
22
|
-
export const SIGNING_SECRET_KV_KEY = 'media:url-signing-secret';
|
|
23
21
|
export function base64UrlEncode(bytes) {
|
|
24
22
|
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
25
23
|
let binary = '';
|
|
@@ -35,63 +33,10 @@ export function base64UrlDecode(text) {
|
|
|
35
33
|
out[i] = binary.charCodeAt(i);
|
|
36
34
|
return out;
|
|
37
35
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
'verify',
|
|
43
|
-
]);
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* The HMAC key for **signing**, creating one if this deployment has never
|
|
47
|
-
* signed anything.
|
|
48
|
-
*
|
|
49
|
-
* `MEDIA_URL_SECRET` wins when an operator sets one. Otherwise a random 32-byte
|
|
50
|
-
* secret is generated once and persisted in KV — which is what makes the whole
|
|
51
|
-
* feature work with **zero configuration**, the entire point of this change.
|
|
52
|
-
*
|
|
53
|
-
* Creating is confined to this function, and this function is called only when
|
|
54
|
-
* minting a URL. {@link loadSigningKey} is what the verify path uses. That
|
|
55
|
-
* split is load-bearing, not stylistic — see the warning there.
|
|
56
|
-
*
|
|
57
|
-
* Deliberately NOT cached in a module-level variable: `src/` holds no
|
|
58
|
-
* module-scope mutable state (see session.ts), and a KV read per sign is cheap
|
|
59
|
-
* next to an image generation. There is a narrow first-use race — two isolates
|
|
60
|
-
* generating simultaneously — so the value is re-read after writing and the
|
|
61
|
-
* stored one wins, collapsing the window to URLs minted in that same instant.
|
|
62
|
-
*/
|
|
63
|
-
export async function resolveSigningKey(secret, store) {
|
|
64
|
-
let material = secret?.trim();
|
|
65
|
-
if (!material) {
|
|
66
|
-
material = (await store.get(SIGNING_SECRET_KV_KEY)) ?? undefined;
|
|
67
|
-
if (!material) {
|
|
68
|
-
const generated = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
|
|
69
|
-
await store.put(SIGNING_SECRET_KV_KEY, generated);
|
|
70
|
-
// Re-read so a concurrent generator's value wins consistently for both.
|
|
71
|
-
material = (await store.get(SIGNING_SECRET_KV_KEY)) ?? generated;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return importKey(material);
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* The HMAC key for **verifying**. Read-only: returns `undefined` when no secret
|
|
78
|
-
* is stored, and never creates one.
|
|
79
|
-
*
|
|
80
|
-
* This is not a stylistic split. KV is eventually consistent — a `put` on one
|
|
81
|
-
* isolate is not immediately visible to another — so the verify path can and
|
|
82
|
-
* will see a miss for a secret that exists. If verification created-on-miss
|
|
83
|
-
* (as it did originally), that transient miss would mint a NEW secret and
|
|
84
|
-
* overwrite the one every outstanding link was signed with, permanently 403ing
|
|
85
|
-
* media that had been signed perfectly correctly. A self-inflicted denial of
|
|
86
|
-
* service, triggered by ordinary traffic, and irreversible once the old secret
|
|
87
|
-
* is gone.
|
|
88
|
-
*
|
|
89
|
-
* No secret stored ⇒ nothing was ever legitimately signed ⇒ refuse.
|
|
90
|
-
*/
|
|
91
|
-
export async function loadSigningKey(secret, store) {
|
|
92
|
-
const material = secret?.trim() || (await store.get(SIGNING_SECRET_KV_KEY));
|
|
93
|
-
return material ? importKey(material) : undefined;
|
|
94
|
-
}
|
|
36
|
+
// The KV-backed signing-secret helpers (`resolveSigningKey` /
|
|
37
|
+
// `loadSigningKey` / `SecretStore`) went with the Cloudflare Worker: on
|
|
38
|
+
// mcp-host the signing key is `MCP_BLOB_SIGNING_KEY`, injected by the host and
|
|
39
|
+
// used by `blob-store.ts` (fleet-audit#1251).
|
|
95
40
|
/** The bytes covered by a signature: the object key and the expiry, nothing else. */
|
|
96
41
|
function payload(key, expiresAtMs) {
|
|
97
42
|
return new TextEncoder().encode(`${key}\n${expiresAtMs}`);
|
package/dist/models.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The generalist workhorse (Nano Banana 2) — overridable per-call and via
|
|
3
|
-
* GEMINI_IMAGE_MODEL.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* The generalist workhorse (Nano Banana 2.1) — overridable per-call and via
|
|
3
|
+
* GEMINI_IMAGE_MODEL. Replaced gemini-3.1-flash-image (Nano Banana 2), which
|
|
4
|
+
* Google deprecated 2026-10-06 with no shutdown date yet; 2.1 is also half the
|
|
5
|
+
* per-image price ($0.0336 vs $0.067 at 1K). The premium gemini-3-pro-image
|
|
6
|
+
* is a deliberate opt-in: it costs more and needs a funded account, so callers
|
|
7
|
+
* reach for it via the `model` param when the task warrants it (see
|
|
8
|
+
* MODEL_CHOICE_GUIDE).
|
|
6
9
|
*/
|
|
7
|
-
export const DEFAULT_IMAGE_MODEL = 'gemini-
|
|
10
|
+
export const DEFAULT_IMAGE_MODEL = 'gemini-nano-banana-2.1';
|
|
8
11
|
/**
|
|
9
12
|
* Default video model (omni, Interactions API) — overridable per-call.
|
|
10
13
|
*
|
|
@@ -28,10 +31,12 @@ export function resolveModel(perCall, envOverride) {
|
|
|
28
31
|
* Keep only Gemini image-generation models (the Nano Banana family) and strip
|
|
29
32
|
* the `models/` prefix. Excludes `imagen-*` — those contain the substring
|
|
30
33
|
* "image" but use a different `:predict` API this server doesn't implement.
|
|
34
|
+
* `nano-banana` is matched too: `gemini-nano-banana-2.1`, the default, has no
|
|
35
|
+
* "image" in its id and would otherwise be invisible to gemini_list_models.
|
|
31
36
|
*/
|
|
32
37
|
export function filterImageModels(raw) {
|
|
33
38
|
return raw
|
|
34
|
-
.filter((m) => /image/i.test(m.name ?? '') && !/imagen/i.test(m.name ?? ''))
|
|
39
|
+
.filter((m) => /image|nano-banana/i.test(m.name ?? '') && !/imagen/i.test(m.name ?? ''))
|
|
35
40
|
.map((m) => ({
|
|
36
41
|
id: (m.name ?? '').replace(/^models\//, ''),
|
|
37
42
|
displayName: m.displayName ?? '',
|
package/dist/pricing.js
CHANGED
|
@@ -57,6 +57,10 @@ export const PRICED_AT = '2026-09-09';
|
|
|
57
57
|
export const RATE_CARD = Object.freeze({
|
|
58
58
|
'gemini-3-pro-image': { input: 2.0, text_output: 12.0, image_output: 120.0 },
|
|
59
59
|
'gemini-3.1-flash-image': { input: 0.5, text_output: 3.0, image_output: 60.0 },
|
|
60
|
+
// Nano Banana 2.1 (GA 2026-10-06; rates read 2026-10-14, after PRICED_AT):
|
|
61
|
+
// a pricier input and text rate than 2 but half the image rate, so a 1K
|
|
62
|
+
// image is $0.0336 rather than $0.067.
|
|
63
|
+
'gemini-nano-banana-2.1': { input: 1.5, text_output: 7.5, image_output: 30.0 },
|
|
60
64
|
'gemini-3.1-flash-lite-image': { input: 0.25, text_output: 1.5, image_output: 30.0 },
|
|
61
65
|
// Google publishes 2.5-flash-image's output as $0.039 PER IMAGE at 1290
|
|
62
66
|
// tokens, not as a per-1M rate. 0.039 / 1290 * 1e6 = 30.23 — a back-derived
|
package/dist/storage/media.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* is a thin wrapper over the pre-existing `resolveOutputDir` + `writeMedia`
|
|
13
13
|
* behaviour and must stay byte-for-byte equivalent to it.
|
|
14
14
|
*/
|
|
15
|
+
import { mapWithConcurrency } from '@chrischall/mcp-utils';
|
|
15
16
|
import { base64ToBytes } from '../bytes.js';
|
|
16
17
|
import { signedLinks } from '../signed-url.js';
|
|
17
18
|
import { tenantResolver } from '../tenant.js';
|
|
@@ -115,19 +116,6 @@ const MAX_SIDECAR_READS = 40;
|
|
|
115
116
|
const SIDECAR_PROMPT_MAX = 500;
|
|
116
117
|
/** How many record reads a single listing will have open at once. */
|
|
117
118
|
const DECORATE_CONCURRENCY = 8;
|
|
118
|
-
/** `Promise.all` with a ceiling on how many run at once, preserving order. */
|
|
119
|
-
async function mapLimit(items, limit, fn) {
|
|
120
|
-
const out = new Array(items.length);
|
|
121
|
-
let next = 0;
|
|
122
|
-
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
123
|
-
while (next < items.length) {
|
|
124
|
-
const i = next++;
|
|
125
|
-
out[i] = await fn(items[i]);
|
|
126
|
-
}
|
|
127
|
-
});
|
|
128
|
-
await Promise.all(workers);
|
|
129
|
-
return out;
|
|
130
|
-
}
|
|
131
119
|
/**
|
|
132
120
|
* The hosted-connector sink: one R2 object per generated item.
|
|
133
121
|
*
|
|
@@ -267,7 +255,7 @@ export function createR2Sink(bucket, opts) {
|
|
|
267
255
|
'They expire (see media[].expires_at), and the objects behind them are cleaned up on a retention schedule. ' +
|
|
268
256
|
'This connector has no filesystem, so `output_dir` is ignored — but the interaction id is recorded beside each image, so continue_last and chain recovery still work.'
|
|
269
257
|
: 'Generated media was stored, but this connector has no public media URL configured and no signing route available, so the values above are bare object keys rather than fetchable links. ' +
|
|
270
|
-
'
|
|
258
|
+
'The host did not provide a blob store (mcp-host injects MCP_BLOB_BASE_URL and MCP_BLOB_SIGNING_KEY) — ask the server operator, or pass inline: true to receive the bytes directly.',
|
|
271
259
|
};
|
|
272
260
|
/**
|
|
273
261
|
* List this account's objects, newest-first, splitting media from the sidecar
|
|
@@ -334,7 +322,7 @@ export function createR2Sink(bucket, opts) {
|
|
|
334
322
|
// a hundred concurrent store reads while the id lookups were carefully
|
|
335
323
|
// budgeted. One tool call should not open a hundred connections to
|
|
336
324
|
// decorate a page.
|
|
337
|
-
return
|
|
325
|
+
return mapWithConcurrency(entries, DECORATE_CONCURRENCY, async (entry) => {
|
|
338
326
|
const link = await describe(entry.key, expiresAtMs);
|
|
339
327
|
// Only where the walk actually saw a record — media with none (anything
|
|
340
328
|
// generated before sidecars existed, and anything whose write failed)
|
package/dist/tools/_confirm.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { confirmTokenParam, confirmWrite } from '@chrischall/mcp-utils';
|
|
2
2
|
import { previewImageInput, previewVideoInput } from '../images.js';
|
|
3
3
|
export { confirmTokenParam };
|
|
4
4
|
/**
|
|
@@ -10,35 +10,15 @@ export function confirmNote(lead = 'Asks the user to confirm first') {
|
|
|
10
10
|
'and a confirmToken, and only a repeat call with that token proceeds (see MCP_CONFIRM_MODE).';
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* The gate itself is the fleet's shared `confirmWrite` from
|
|
14
|
+
* `@chrischall/mcp-utils` (fleet-audit#1376 — this file used to carry its own
|
|
15
|
+
* adapter over `requireConfirmationWithFallback` + `confirmationFromEnv`). It
|
|
16
|
+
* binds the request, the payload, the displayed preview and the validated
|
|
17
|
+
* args into both the token and the elicitation acceptance. One API key per
|
|
18
|
+
* process (a hosted deployment runs one child per account), so every call
|
|
19
|
+
* passes `account: undefined`.
|
|
17
20
|
*/
|
|
18
|
-
export
|
|
19
|
-
const preview = {
|
|
20
|
-
action: o.description,
|
|
21
|
-
method: o.method,
|
|
22
|
-
path: o.path,
|
|
23
|
-
...(o.body !== undefined ? { willSend: o.body } : {}),
|
|
24
|
-
};
|
|
25
|
-
return requireConfirmationWithFallback(ctx, confirmationFromEnv({
|
|
26
|
-
action: o.action,
|
|
27
|
-
message: o.message,
|
|
28
|
-
details: preview,
|
|
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),
|
|
34
|
-
confirmToken: o.confirmToken,
|
|
35
|
-
subject: () => ({ target: o.target, payload: o.payload, preview }),
|
|
36
|
-
}));
|
|
37
|
-
}
|
|
38
|
-
function withoutConfirmToken(args) {
|
|
39
|
-
const { confirmToken: _omit, ...rest } = args;
|
|
40
|
-
return rest;
|
|
41
|
-
}
|
|
21
|
+
export { confirmWrite };
|
|
42
22
|
/**
|
|
43
23
|
* Confirm-gate for a Gemini tool that ships LOCAL input files (image/video
|
|
44
24
|
* paths) to Google. Proceeds straight away when the call has no local file
|
|
@@ -60,12 +40,12 @@ export async function confirmLocalInputs(ctx, o) {
|
|
|
60
40
|
tool: o.tool,
|
|
61
41
|
action: o.action,
|
|
62
42
|
message: 'Review and confirm sending these local files to Google:',
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
path: o.endpoint,
|
|
66
|
-
body: { inputs },
|
|
43
|
+
summary: o.description,
|
|
44
|
+
account: undefined,
|
|
67
45
|
target: '',
|
|
46
|
+
request: { method: 'POST', path: o.endpoint },
|
|
68
47
|
payload: { request: o.request, inputs },
|
|
48
|
+
willSend: { inputs },
|
|
69
49
|
args: o.request,
|
|
70
50
|
confirmToken: o.confirmToken,
|
|
71
51
|
});
|
package/dist/tools/files.js
CHANGED
|
@@ -162,12 +162,12 @@ export function registerFileTools(server, client) {
|
|
|
162
162
|
tool: 'gemini_upload_file',
|
|
163
163
|
action: 'file.upload',
|
|
164
164
|
message: 'Review and confirm uploading this local file to Google:',
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
path: '/upload/v1beta/files',
|
|
168
|
-
body: { inputs },
|
|
165
|
+
summary: 'Upload a local file to the Gemini Files API',
|
|
166
|
+
account: undefined,
|
|
169
167
|
target: resolved,
|
|
168
|
+
request: { method: 'POST', path: '/upload/v1beta/files' },
|
|
170
169
|
payload: { inputs, display_name: args.display_name },
|
|
170
|
+
willSend: { inputs },
|
|
171
171
|
args,
|
|
172
172
|
confirmToken: args.confirmToken,
|
|
173
173
|
});
|
|
@@ -255,7 +255,7 @@ export function registerFileTools(server, client) {
|
|
|
255
255
|
const fresh = await sink.resign(args.r2_key.trim());
|
|
256
256
|
if (!fresh) {
|
|
257
257
|
// Remediation goes in the MESSAGE: MCP serialization drops hints.
|
|
258
|
-
throw new McpToolError(`No stored media for r2_key "${args.r2_key}" — it has probably passed its retention window. Re-run the generation to get a new one.`, { hint:
|
|
258
|
+
throw new McpToolError(`No stored media for r2_key "${args.r2_key}" — it has probably passed its retention window. Re-run the generation to get a new one.`, { hint: "Objects are swept on the host's blob-store retention schedule; signed links last at most 24h." });
|
|
259
259
|
}
|
|
260
260
|
return minifiedResult({
|
|
261
261
|
url: fresh.ref,
|
|
@@ -376,10 +376,10 @@ export function registerFileTools(server, client) {
|
|
|
376
376
|
tool: 'gemini_delete_file',
|
|
377
377
|
action: 'file.delete',
|
|
378
378
|
message: 'Review and confirm this deletion:',
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
path: `/v1beta/${args.file_uri}`,
|
|
379
|
+
summary: 'Delete an uploaded Gemini file',
|
|
380
|
+
account: undefined,
|
|
382
381
|
target: args.file_uri,
|
|
382
|
+
request: { method: 'DELETE', path: `/v1beta/${args.file_uri}` },
|
|
383
383
|
payload: { file_uri: args.file_uri },
|
|
384
384
|
args,
|
|
385
385
|
confirmToken: args.confirmToken,
|
package/dist/tools/interact.js
CHANGED
|
@@ -121,7 +121,7 @@ export function registerInteractTools(server, client) {
|
|
|
121
121
|
image_size: z
|
|
122
122
|
.enum(IMAGE_SIZES)
|
|
123
123
|
.optional()
|
|
124
|
-
.describe('Output resolution (512 = 0.5K,
|
|
124
|
+
.describe('Output resolution (512 = 0.5K, gemini-3.1-flash-image only)'),
|
|
125
125
|
thinking_level: z
|
|
126
126
|
.enum(['minimal', 'high'])
|
|
127
127
|
.optional()
|