@chrischall/gemini-mcp 2.4.0 → 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/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
- * it uses the SAME injected `fetchImpl` as every upstream call — which is
350
- * what makes it mockable in tests and correct on the Worker.
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, { ...opts, fetchImpl: this.fetchImpl, timeoutMs: resolveTimeoutMs(opts.timeoutMs) });
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.
@@ -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. 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.
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 v4 = isIpv6Literal ? undefined : ipv4Octets(host);
199
- const v6 = isIpv6Literal ? ipv6Groups(host) : undefined;
200
- const blocked = isIpv6Literal
201
- // Fail CLOSED on an IPv6 literal we cannot parse: a guard that allows what
202
- // it does not understand is the wrong default for this control.
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
- /** File extension for a media MIME (image/video/audio). Falls back to the MIME
131
- * subtype, so an unforeseen preview type still writes with a sane extension. */
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 m = mimeType.toLowerCase();
134
- if (m.includes('jpeg'))
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
- /** Import raw secret material as an HMAC key. */
39
- async function importKey(material) {
40
- return crypto.subtle.importKey('raw', new TextEncoder().encode(material), { name: 'HMAC', hash: 'SHA-256' }, false, [
41
- 'sign',
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}`);
@@ -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
- 'Set MEDIA_PUBLIC_BASE_URL on the Worker, or pass inline: true to receive the bytes directly.',
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 mapLimit(entries, DECORATE_CONCURRENCY, async (entry) => {
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)
@@ -1,4 +1,4 @@
1
- import { confirmationFromEnv, confirmTokenParam, requireConfirmationWithFallback } from '@chrischall/mcp-utils';
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
- * Confirm a mutating call before it runs: an elicitation prompt where the
14
- * client supports one, otherwise the preview-plus-token fallback governed by
15
- * MCP_CONFIRM_MODE. The preview is the same method/path/willSend shape the old
16
- * dry-run returned.
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 async function confirmWrite(ctx, o) {
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
- description: o.description,
64
- method: 'POST',
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
  });
@@ -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
- description: 'Upload a local file to the Gemini Files API',
166
- method: 'POST',
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: 'Objects are swept on a retention schedule (MEDIA_TTL_DAYS, default 7 days).' });
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
- description: 'Delete an uploaded Gemini file',
380
- method: 'DELETE',
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,
@@ -119,10 +119,10 @@ export function registerLibraryTools(server, client) {
119
119
  tool: 'gemini_delete_character',
120
120
  action: 'character.delete',
121
121
  message: 'Review and confirm this deletion:',
122
- description: 'Delete a saved character (its reference image and description)',
123
- method: 'DELETE',
124
- path: `characters/${args.name}`,
122
+ summary: 'Delete a saved character (its reference image and description)',
123
+ account: undefined,
125
124
  target: args.name,
125
+ request: { method: 'DELETE', path: `characters/${args.name}` },
126
126
  payload: { name: args.name },
127
127
  args,
128
128
  confirmToken: args.confirmToken,
@@ -189,10 +189,10 @@ export function registerLibraryTools(server, client) {
189
189
  tool: 'gemini_delete_style',
190
190
  action: 'style.delete',
191
191
  message: 'Review and confirm this deletion:',
192
- description: 'Delete a saved style preset',
193
- method: 'DELETE',
194
- path: `styles/${args.name}`,
192
+ summary: 'Delete a saved style preset',
193
+ account: undefined,
195
194
  target: args.name,
195
+ request: { method: 'DELETE', path: `styles/${args.name}` },
196
196
  payload: { name: args.name },
197
197
  args,
198
198
  confirmToken: args.confirmToken,
@@ -547,7 +547,7 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
547
547
  const unavailable = persisted.some((p) => p.unavailable)
548
548
  ? {
549
549
  media_url_unavailable: 'The media was stored but no public URL could be generated for it, so the values above are bare object keys, not links. ' +
550
- 'This is a connector misconfiguration: set MEDIA_PUBLIC_BASE_URL, or use inline: true to receive the bytes directly.',
550
+ 'This is a hosting misconfiguration — the host did not provide a blob store (mcp-host injects MCP_BLOB_BASE_URL and MCP_BLOB_SIGNING_KEY) — so ask the server operator, or use inline: true to receive the bytes directly.',
551
551
  }
552
552
  : {};
553
553
  const detailed = persisted.filter((p) => p.key !== undefined);
@@ -86,7 +86,7 @@ export function registerUploadUrlTools(server, client) {
86
86
  '(the signature in the URL is the auth, and it covers the content type). On a 2xx, reference the `r2_key` from ' +
87
87
  'THIS result — not the PUT response: images_r2_keys on generation tools (master_images_r2_keys on ' +
88
88
  'gemini_image_set), gemini_save_character to keep it with no expiry, or gemini_upload_file({ r2_key }) for a ' +
89
- '~48h files/… reference. Uploads themselves follow the media retention schedule (default 7 days).',
89
+ '~48h files/… reference. Uploads themselves follow the host\'s blob-store retention schedule.',
90
90
  });
91
91
  });
92
92
  }
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Single source of the server version. release-please bumps the literal below. */
2
- export const VERSION = '2.4.0'; // x-release-please-version
2
+ export const VERSION = '2.4.1'; // x-release-please-version