@chrischall/gemini-mcp 1.0.4 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +75 -1
- package/dist/bundle.js +854 -137
- package/dist/bytes.js +46 -0
- package/dist/client.js +194 -17
- package/dist/fetch-image.js +361 -0
- package/dist/images.js +0 -19
- package/dist/index.js +2 -1
- package/dist/inputs.js +165 -0
- package/dist/session.js +39 -0
- package/dist/storage/media.js +2 -13
- package/dist/tools/files.js +188 -0
- package/dist/tools/generate.js +21 -12
- package/dist/tools/interact.js +12 -5
- package/dist/tools/music.js +10 -4
- package/dist/tools/set.js +29 -7
- package/dist/tools/shared.js +44 -6
- package/dist/tools/video.js +10 -4
- package/dist/upload-endpoint.js +79 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/gemini-mcp/SKILL.md +65 -6
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { McpToolError } from '@chrischall/mcp-utils';
|
|
2
|
+
import { formatMb } from './bytes.js';
|
|
3
|
+
/**
|
|
4
|
+
* Server-side fetching of `images_url` references.
|
|
5
|
+
*
|
|
6
|
+
* The point of this module is that image BYTES never travel through the model's
|
|
7
|
+
* context window. A caller hands over an `https://` URL — a few dozen tokens —
|
|
8
|
+
* and the *server* pulls the bytes down and turns them into an `inline_data`
|
|
9
|
+
* part or a Files API upload. The alternative, `images_base64`, costs roughly
|
|
10
|
+
* 14k tokens for a modest JPEG and is silently corrupted whenever a host
|
|
11
|
+
* truncates the read that produced it.
|
|
12
|
+
*
|
|
13
|
+
* Fetching a caller-supplied URL from inside the server is an SSRF primitive,
|
|
14
|
+
* and on the stdio build the server runs on the user's own machine, next to
|
|
15
|
+
* their LAN. So this is deliberately narrow:
|
|
16
|
+
*
|
|
17
|
+
* - **https only.** `http:`, `file:`, `data:`, `gopher:` and friends are out.
|
|
18
|
+
* - **No private/loopback/link-local destinations** — including the cloud
|
|
19
|
+
* metadata address, the classic escalation target.
|
|
20
|
+
* - **Redirects are followed MANUALLY**, revalidating every hop. `redirect:
|
|
21
|
+
* 'follow'` would let a public URL bounce to `http://169.254.169.254/` with
|
|
22
|
+
* no further check, which makes the origin check theatre.
|
|
23
|
+
* - **A hard byte cap enforced while streaming**, not from `Content-Length` —
|
|
24
|
+
* a hostile or merely wrong server can under-report or omit it.
|
|
25
|
+
*
|
|
26
|
+
* Module scope stays side-effect-free: `src/worker.ts` reaches this file
|
|
27
|
+
* through the tool registrars, so anything executed here would run during
|
|
28
|
+
* Cloudflare isolate startup.
|
|
29
|
+
*/
|
|
30
|
+
/** Hard ceiling on a fetched image. Above this the URL is rejected outright. */
|
|
31
|
+
export const IMAGE_URL_MAX_BYTES = 15 * 1024 * 1024;
|
|
32
|
+
/**
|
|
33
|
+
* Above this an image is uploaded to the Files API and referenced by uri
|
|
34
|
+
* instead of inlined. `generateContent` caps a whole request at ~20 MB, so a
|
|
35
|
+
* couple of large inline references would fail the request itself — and a
|
|
36
|
+
* by-reference part costs nothing to repeat across calls.
|
|
37
|
+
*/
|
|
38
|
+
export const IMAGE_INLINE_MAX_BYTES = 6 * 1024 * 1024;
|
|
39
|
+
/** Redirect hops followed before giving up. */
|
|
40
|
+
const MAX_REDIRECTS = 5;
|
|
41
|
+
/**
|
|
42
|
+
* Per-hop abort budget. Matches the client's default upstream timeout so a URL
|
|
43
|
+
* fetch is bounded like every other network call this server makes; callers
|
|
44
|
+
* override it with the same `timeout_ms` resolution (see `resolveTimeoutMs`).
|
|
45
|
+
*/
|
|
46
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 60_000;
|
|
47
|
+
/** Parse an IPv4 literal into its four octets, or `undefined` if it isn't one. */
|
|
48
|
+
function ipv4Octets(host) {
|
|
49
|
+
const parts = host.split('.');
|
|
50
|
+
if (parts.length !== 4)
|
|
51
|
+
return undefined;
|
|
52
|
+
const octets = parts.map((p) => (/^\d{1,3}$/.test(p) ? Number(p) : NaN));
|
|
53
|
+
return octets.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) ? octets : undefined;
|
|
54
|
+
}
|
|
55
|
+
/** True for addresses that are not on the public internet. */
|
|
56
|
+
function isPrivateIpv4([a, b]) {
|
|
57
|
+
if (a === 0 || a === 127)
|
|
58
|
+
return true; // this-host / loopback
|
|
59
|
+
if (a === 10)
|
|
60
|
+
return true; // RFC1918
|
|
61
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
62
|
+
return true; // RFC1918
|
|
63
|
+
if (a === 192 && b === 168)
|
|
64
|
+
return true; // RFC1918
|
|
65
|
+
if (a === 169 && b === 254)
|
|
66
|
+
return true; // link-local — includes 169.254.169.254
|
|
67
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
68
|
+
return true; // CGNAT
|
|
69
|
+
if (a === 192 && b === 0)
|
|
70
|
+
return true; // IETF protocol assignments
|
|
71
|
+
if (a === 198 && (b === 18 || b === 19))
|
|
72
|
+
return true; // benchmarking
|
|
73
|
+
if (a >= 224)
|
|
74
|
+
return true; // multicast + reserved
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Expand an IPv6 literal into its eight 16-bit groups, or `undefined` if it
|
|
79
|
+
* isn't one. Handles `::` compression and a trailing dotted-quad
|
|
80
|
+
* (`::ffff:127.0.0.1`) — the latter never comes out of `new URL`, which
|
|
81
|
+
* re-serializes it as hex, but a redirect `Location` is parsed before
|
|
82
|
+
* normalization and a human writes it that way.
|
|
83
|
+
*/
|
|
84
|
+
function ipv6Groups(host) {
|
|
85
|
+
if (!host.includes(':'))
|
|
86
|
+
return undefined;
|
|
87
|
+
let text = host;
|
|
88
|
+
const tail = [];
|
|
89
|
+
// A trailing dotted-quad occupies the last two groups.
|
|
90
|
+
const dotted = /:((?:\d{1,3}\.){3}\d{1,3})$/.exec(text);
|
|
91
|
+
if (dotted) {
|
|
92
|
+
const octets = ipv4Octets(dotted[1]);
|
|
93
|
+
if (!octets)
|
|
94
|
+
return undefined;
|
|
95
|
+
tail.push((octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]);
|
|
96
|
+
text = text.slice(0, dotted.index);
|
|
97
|
+
}
|
|
98
|
+
const halves = text.split('::');
|
|
99
|
+
if (halves.length > 2)
|
|
100
|
+
return undefined;
|
|
101
|
+
const parse = (part) => {
|
|
102
|
+
if (part === '')
|
|
103
|
+
return [];
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const piece of part.split(':')) {
|
|
106
|
+
if (!/^[0-9a-f]{1,4}$/.test(piece))
|
|
107
|
+
return undefined;
|
|
108
|
+
out.push(parseInt(piece, 16));
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
};
|
|
112
|
+
const head = parse(halves[0]);
|
|
113
|
+
const rest = halves.length === 2 ? parse(halves[1]) : [];
|
|
114
|
+
if (!head || !rest)
|
|
115
|
+
return undefined;
|
|
116
|
+
const explicit = [...head, ...rest, ...tail];
|
|
117
|
+
if (halves.length === 2) {
|
|
118
|
+
if (explicit.length > 7)
|
|
119
|
+
return undefined;
|
|
120
|
+
return [...head, ...Array(8 - explicit.length).fill(0), ...rest, ...tail];
|
|
121
|
+
}
|
|
122
|
+
return explicit.length === 8 ? explicit : undefined;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The /96 prefixes that carry an IPv4 address in their final 32 bits, as the
|
|
126
|
+
* first six groups. A literal matching any of them is judged on the v4 address
|
|
127
|
+
* it embeds, not on the prefix.
|
|
128
|
+
*
|
|
129
|
+
* `::ffff:0:0/96` is the one that matters operationally — stacks really do
|
|
130
|
+
* resolve `[::ffff:7f00:1]` to loopback, and `new URL` normalizes
|
|
131
|
+
* `[::ffff:127.0.0.1]` into exactly that hex spelling, which is how a guard
|
|
132
|
+
* that pattern-matched the dotted form came to never fire.
|
|
133
|
+
*
|
|
134
|
+
* The rest are defense in depth: `::/96` is deprecated IPv4-compatible,
|
|
135
|
+
* `::ffff:0:0:0/96` is deprecated SIIT (RFC 2765), and `64:ff9b::/96` is the
|
|
136
|
+
* NAT64 well-known prefix (RFC 6052), which needs a translator on the network
|
|
137
|
+
* to route at all — and whose §3.1 already forbids embedding non-global v4.
|
|
138
|
+
* They are listed anyway because a function that understands one v4-carrying
|
|
139
|
+
* prefix and not its siblings is precisely the shape the original bypass had.
|
|
140
|
+
*/
|
|
141
|
+
const V4_BEARING_PREFIXES = [
|
|
142
|
+
[0, 0, 0, 0, 0, 0xffff], // ::ffff:0:0/96 — IPv4-mapped
|
|
143
|
+
[0, 0, 0, 0, 0, 0], // ::/96 — IPv4-compatible (deprecated)
|
|
144
|
+
[0, 0, 0, 0, 0xffff, 0], // ::ffff:0:0:0/96 — IPv4-translated (deprecated SIIT)
|
|
145
|
+
[0x64, 0xff9b, 0, 0, 0, 0], // 64:ff9b::/96 — NAT64 well-known (RFC 6052)
|
|
146
|
+
];
|
|
147
|
+
/**
|
|
148
|
+
* True for IPv6 addresses that are not on the public internet.
|
|
149
|
+
*
|
|
150
|
+
* Anything carrying an IPv4 address (see {@link V4_BEARING_PREFIXES}) is
|
|
151
|
+
* decided by running that address through the same {@link isPrivateIpv4} table
|
|
152
|
+
* as a bare v4 literal — so the two guards can never disagree about whether
|
|
153
|
+
* 10.0.0.5 is private.
|
|
154
|
+
*/
|
|
155
|
+
function isPrivateIpv6(groups) {
|
|
156
|
+
if (groups.every((g) => g === 0))
|
|
157
|
+
return true; // ::
|
|
158
|
+
if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1)
|
|
159
|
+
return true; // ::1
|
|
160
|
+
if ((groups[0] & 0xffc0) === 0xfe80)
|
|
161
|
+
return true; // fe80::/10 link-local
|
|
162
|
+
if ((groups[0] & 0xfe00) === 0xfc00)
|
|
163
|
+
return true; // fc00::/7 unique-local
|
|
164
|
+
if (V4_BEARING_PREFIXES.some((prefix) => prefix.every((g, i) => groups[i] === g))) {
|
|
165
|
+
return isPrivateIpv4([groups[6] >> 8, groups[6] & 0xff, groups[7] >> 8, groups[7] & 0xff]);
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Reject a URL that isn't a plain public https endpoint. Hostname-based, so it
|
|
171
|
+
* cannot catch a public name that RESOLVES to a private address (DNS rebinding)
|
|
172
|
+
* — closing that needs resolve-then-pin, which neither Node's nor workerd's
|
|
173
|
+
* fetch exposes. It does close the direct-literal and redirect-to-metadata
|
|
174
|
+
* cases, which is what a prompt-injected URL actually reaches for.
|
|
175
|
+
*/
|
|
176
|
+
function assertPublicHttpsUrl(raw, requested) {
|
|
177
|
+
let url;
|
|
178
|
+
try {
|
|
179
|
+
url = new URL(raw);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
throw new McpToolError(`Not a valid URL: ${raw}`, {
|
|
183
|
+
hint: 'images_url entries must be absolute https:// URLs.',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (url.protocol !== 'https:') {
|
|
187
|
+
throw new McpToolError(`Refusing to fetch ${describeUrl(raw, requested)}: only https:// URLs are fetched (got "${url.protocol}").`, {
|
|
188
|
+
hint: 'Use an https:// URL, or send the bytes with images_base64 / gemini_upload_file.',
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
// `new URL` always brackets an IPv6 hostname, which is the reliable signal
|
|
192
|
+
// that this is a literal and not a name.
|
|
193
|
+
const rawHost = url.hostname.toLowerCase();
|
|
194
|
+
const isIpv6Literal = rawHost.startsWith('[') && rawHost.endsWith(']');
|
|
195
|
+
const host = isIpv6Literal ? rawHost.slice(1, -1) : rawHost;
|
|
196
|
+
const v4 = isIpv6Literal ? undefined : ipv4Octets(host);
|
|
197
|
+
const v6 = isIpv6Literal ? ipv6Groups(host) : undefined;
|
|
198
|
+
const blocked = isIpv6Literal
|
|
199
|
+
// Fail CLOSED on an IPv6 literal we cannot parse: a guard that allows what
|
|
200
|
+
// it does not understand is the wrong default for this control.
|
|
201
|
+
? (v6 === undefined || isPrivateIpv6(v6))
|
|
202
|
+
: host === 'localhost' ||
|
|
203
|
+
host.endsWith('.localhost') ||
|
|
204
|
+
host.endsWith('.local') ||
|
|
205
|
+
(v4 ? isPrivateIpv4(v4) : false);
|
|
206
|
+
if (blocked) {
|
|
207
|
+
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.' });
|
|
208
|
+
}
|
|
209
|
+
return url;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Last path segment of a URL, for the Files API display name. Cosmetic — the
|
|
213
|
+
* API keys on the resource name it returns, not on this — so an unparseable
|
|
214
|
+
* URL degrades to `fallback` rather than failing the upload.
|
|
215
|
+
*
|
|
216
|
+
* `fallback` is REQUIRED, deliberately. This function was created by merging
|
|
217
|
+
* two byte-identical copies that differed only in their default (`'image'` in
|
|
218
|
+
* `inputs.ts`, `'upload'` in `tools/files.ts`), and taking one of those
|
|
219
|
+
* defaults silently changed the other caller's behaviour — `gemini_upload_file`
|
|
220
|
+
* started naming a fallback upload "image" even for an mp4, and disagreed with
|
|
221
|
+
* the other two branches of its own handler. With no default, merging callers
|
|
222
|
+
* that disagree stops being something you can do by accident.
|
|
223
|
+
*/
|
|
224
|
+
export function fileNameFromUrl(url, fallback) {
|
|
225
|
+
try {
|
|
226
|
+
const name = new URL(url).pathname.split('/').filter(Boolean).pop();
|
|
227
|
+
return name ? decodeURIComponent(name).slice(0, 120) : fallback;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return fallback;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** "<url>" — or "<url> (redirected from <requested>)" when they differ. */
|
|
234
|
+
function describeUrl(current, requested) {
|
|
235
|
+
return current === requested ? current : `${current} (redirected from ${requested})`;
|
|
236
|
+
}
|
|
237
|
+
/** Read a response body, aborting as soon as it exceeds `maxBytes`. */
|
|
238
|
+
async function readCapped(res, maxBytes, url, requested) {
|
|
239
|
+
const declared = Number(res.headers.get('content-length'));
|
|
240
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
241
|
+
throw tooBig(url, requested, declared, maxBytes);
|
|
242
|
+
}
|
|
243
|
+
const reader = res.body?.getReader();
|
|
244
|
+
if (!reader) {
|
|
245
|
+
// No streaming body (some mocked/edge responses) — fall back to buffering,
|
|
246
|
+
// then apply the same cap. Correct, just less eager.
|
|
247
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
248
|
+
if (buf.byteLength > maxBytes)
|
|
249
|
+
throw tooBig(url, requested, buf.byteLength, maxBytes);
|
|
250
|
+
return buf;
|
|
251
|
+
}
|
|
252
|
+
const chunks = [];
|
|
253
|
+
let total = 0;
|
|
254
|
+
try {
|
|
255
|
+
for (;;) {
|
|
256
|
+
const { done, value } = await reader.read();
|
|
257
|
+
if (done)
|
|
258
|
+
break;
|
|
259
|
+
if (!value)
|
|
260
|
+
continue;
|
|
261
|
+
total += value.byteLength;
|
|
262
|
+
if (total > maxBytes) {
|
|
263
|
+
await reader.cancel().catch(() => { });
|
|
264
|
+
throw tooBig(url, requested, total, maxBytes, true);
|
|
265
|
+
}
|
|
266
|
+
chunks.push(value);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
reader.releaseLock?.();
|
|
271
|
+
}
|
|
272
|
+
const out = new Uint8Array(total);
|
|
273
|
+
let offset = 0;
|
|
274
|
+
for (const c of chunks) {
|
|
275
|
+
out.set(c, offset);
|
|
276
|
+
offset += c.byteLength;
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
/** A transport-level failure, told apart from a plain unreachable host. */
|
|
281
|
+
function fetchFailure(err, current, requested, timeoutMs) {
|
|
282
|
+
const name = err?.name;
|
|
283
|
+
if (name === 'TimeoutError' || name === 'AbortError') {
|
|
284
|
+
return new McpToolError(`Fetching ${describeUrl(current, requested)} timed out after ${timeoutMs}ms.`, {
|
|
285
|
+
hint: 'The server accepted the connection but did not finish sending the image. Try a different URL, or raise timeout_ms.',
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
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.' });
|
|
289
|
+
}
|
|
290
|
+
function tooBig(url, requested, size, max, partial = false) {
|
|
291
|
+
return new McpToolError(`Image at ${describeUrl(url, requested)} is ${partial ? 'over' : ''} ${formatMb(size)}, above the ${formatMb(max)} limit for images_url.`, { hint: 'Resize or re-encode the image, or upload it yourself with gemini_upload_file and pass the returned file_uri.' });
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Fetch one image URL server-side, validating protocol, destination, response
|
|
295
|
+
* status, content type and size. Every failure names the offending URL in the
|
|
296
|
+
* MESSAGE — MCP error serialization drops `hint`, and "one of your URLs failed"
|
|
297
|
+
* is useless when a call carried four of them.
|
|
298
|
+
*/
|
|
299
|
+
export async function fetchRemoteImage(requestedUrl, opts = {}) {
|
|
300
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
301
|
+
const maxBytes = opts.maxBytes ?? IMAGE_URL_MAX_BYTES;
|
|
302
|
+
const accept = opts.accept ?? ['image/'];
|
|
303
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
304
|
+
let current = requestedUrl;
|
|
305
|
+
for (let hop = 0;; hop++) {
|
|
306
|
+
const url = assertPublicHttpsUrl(current, requestedUrl);
|
|
307
|
+
// One budget per hop, applied to the body read as well: aborting the signal
|
|
308
|
+
// errors the response stream, so a trickling server cannot outlast it.
|
|
309
|
+
const signal = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined;
|
|
310
|
+
let res;
|
|
311
|
+
try {
|
|
312
|
+
// Manual redirects: every hop goes back through assertPublicHttpsUrl, so
|
|
313
|
+
// a public URL cannot bounce the fetch onto the metadata service.
|
|
314
|
+
res = await doFetch(url.toString(), {
|
|
315
|
+
redirect: 'manual',
|
|
316
|
+
headers: { accept: accept.map((p) => `${p.replace(/\/$/, '')}/*`).join(', ') },
|
|
317
|
+
...(signal ? { signal } : {}),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
throw fetchFailure(err, current, requestedUrl, timeoutMs);
|
|
322
|
+
}
|
|
323
|
+
if (res.status >= 300 && res.status < 400) {
|
|
324
|
+
const location = res.headers.get('location');
|
|
325
|
+
if (!location) {
|
|
326
|
+
throw new McpToolError(`Fetching ${describeUrl(current, requestedUrl)} returned HTTP ${res.status} with no Location header.`);
|
|
327
|
+
}
|
|
328
|
+
if (hop >= MAX_REDIRECTS) {
|
|
329
|
+
throw new McpToolError(`Fetching ${requestedUrl} exceeded ${MAX_REDIRECTS} redirects (last hop: ${location}).`);
|
|
330
|
+
}
|
|
331
|
+
current = new URL(location, url).toString();
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (!res.ok) {
|
|
335
|
+
throw new McpToolError(`Fetching ${describeUrl(current, requestedUrl)} returned HTTP ${res.status} ${res.statusText || ''}`.trim(), {
|
|
336
|
+
hint: 'The URL must be publicly readable — a login page or signed-URL expiry shows up here as 401/403/404.',
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
const contentType = (res.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
|
|
340
|
+
if (!accept.some((prefix) => contentType.startsWith(prefix))) {
|
|
341
|
+
const expected = accept.map((p) => `${p.replace(/\/$/, '')}/*`).join(' or ');
|
|
342
|
+
throw new McpToolError(`${describeUrl(current, requestedUrl)} is not ${accept.length === 1 && accept[0] === 'image/' ? 'an image' : 'usable media'}: ` +
|
|
343
|
+
`the server returned Content-Type "${contentType || '(none)'}", expected ${expected}.`, { hint: 'Link directly to the media file, not to the page that displays it.' });
|
|
344
|
+
}
|
|
345
|
+
let bytes;
|
|
346
|
+
try {
|
|
347
|
+
bytes = await readCapped(res, maxBytes, current, requestedUrl);
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
// The cap is its own actionable error; anything else here is a transport
|
|
351
|
+
// failure mid-body, most usefully reported as one (including the timeout).
|
|
352
|
+
if (err instanceof McpToolError)
|
|
353
|
+
throw err;
|
|
354
|
+
throw fetchFailure(err, current, requestedUrl, timeoutMs);
|
|
355
|
+
}
|
|
356
|
+
if (bytes.byteLength === 0) {
|
|
357
|
+
throw new McpToolError(`${describeUrl(current, requestedUrl)} returned an empty body.`);
|
|
358
|
+
}
|
|
359
|
+
return { bytes, mimeType: contentType, size: bytes.byteLength, finalUrl: current, requestedUrl };
|
|
360
|
+
}
|
|
361
|
+
}
|
package/dist/images.js
CHANGED
|
@@ -215,25 +215,6 @@ export function decodeImageInput(input) {
|
|
|
215
215
|
const buf = Buffer.from(trimmed, 'base64');
|
|
216
216
|
return { base64: trimmed, mimeType: sniffMimeBytes(buf) };
|
|
217
217
|
}
|
|
218
|
-
/** Load images from file paths (via readImageAsInline) and raw base64 strings (via decodeImageInput), paths first. */
|
|
219
|
-
export async function loadImageInputs(paths = [], base64s = []) {
|
|
220
|
-
const fromPaths = await Promise.all(paths.map((p) => readImageAsInline(p)));
|
|
221
|
-
const fromBase64s = base64s.map((b) => decodeImageInput(b));
|
|
222
|
-
return [...fromPaths, ...fromBase64s];
|
|
223
|
-
}
|
|
224
|
-
/**
|
|
225
|
-
* Aggregate image inputs from all sources: clipboard (macOS only), file paths, and base64 strings.
|
|
226
|
-
* Clipboard image is prepended if `from_clipboard` is true.
|
|
227
|
-
*/
|
|
228
|
-
export async function gatherImageInputs(opts) {
|
|
229
|
-
// Only load the clipboard module when actually requested (keeps the child_process
|
|
230
|
-
// path off the default code path).
|
|
231
|
-
const clipboardImages = opts.from_clipboard
|
|
232
|
-
? [await (await import('./clipboard.js')).readClipboardImage()]
|
|
233
|
-
: [];
|
|
234
|
-
const fileImages = await loadImageInputs(opts.images, opts.images_base64);
|
|
235
|
-
return [...clipboardImages, ...fileImages];
|
|
236
|
-
}
|
|
237
218
|
/** Caller-supplied filename → safe base name (no extension). */
|
|
238
219
|
export function baseName(name) {
|
|
239
220
|
// Strip a known media extension (image / video / audio) so writeMedia doesn't
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { registerInteractTools } from './tools/interact.js';
|
|
|
9
9
|
import { registerVideoTools } from './tools/video.js';
|
|
10
10
|
import { registerMusicTools } from './tools/music.js';
|
|
11
11
|
import { registerJobTools } from './tools/jobs.js';
|
|
12
|
+
import { registerFileTools } from './tools/files.js';
|
|
12
13
|
import { client } from './client.js';
|
|
13
14
|
// Load `.env` here rather than in client.ts, which the Cloudflare Worker
|
|
14
15
|
// imports: `import.meta.url` + async I/O at module scope crashes the isolate at
|
|
@@ -30,6 +31,6 @@ await runMcp({
|
|
|
30
31
|
name: 'gemini-mcp',
|
|
31
32
|
version: VERSION,
|
|
32
33
|
banner: '[gemini-mcp] This project was developed and is maintained by AI (Claude). Use at your own discretion.',
|
|
33
|
-
tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerVideoTools, registerMusicTools, registerJobTools],
|
|
34
|
+
tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerVideoTools, registerMusicTools, registerJobTools, registerFileTools],
|
|
34
35
|
deps: client,
|
|
35
36
|
});
|
package/dist/inputs.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { McpToolError } from '@chrischall/mcp-utils';
|
|
2
|
+
import { IMAGE_INLINE_MAX_BYTES, fileNameFromUrl } from './fetch-image.js';
|
|
3
|
+
import { base64ToBytes, bytesToBase64 } from './bytes.js';
|
|
4
|
+
/** True when this call carries at least one image input of any kind. */
|
|
5
|
+
export function hasImageInput(args) {
|
|
6
|
+
return Boolean(args.images?.length ||
|
|
7
|
+
args.images_base64?.length ||
|
|
8
|
+
args.images_url?.length ||
|
|
9
|
+
args.images_file_uris?.length ||
|
|
10
|
+
args.from_clipboard);
|
|
11
|
+
}
|
|
12
|
+
/** Conservative TTL when the API doesn't tell us: documented retention is ~48h. */
|
|
13
|
+
const ASSUMED_TTL_MS = 47 * 60 * 60 * 1000;
|
|
14
|
+
function expiresAtMs(expirationTime, now) {
|
|
15
|
+
if (expirationTime) {
|
|
16
|
+
const t = Date.parse(expirationTime);
|
|
17
|
+
if (Number.isFinite(t))
|
|
18
|
+
return t;
|
|
19
|
+
}
|
|
20
|
+
return now + ASSUMED_TTL_MS;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Resolve every image input a tool was given into {@link ImageInput}s.
|
|
24
|
+
*
|
|
25
|
+
* Order is stable and matches the pre-`images_url` behaviour so existing calls
|
|
26
|
+
* produce byte-identical requests: clipboard, then paths, then base64, then
|
|
27
|
+
* fetched URLs, then Files API references.
|
|
28
|
+
*
|
|
29
|
+
* A URL repeated within one call is fetched ONCE. That is what lets
|
|
30
|
+
* `gemini_image_set` hand the same reference to the master and all N scene
|
|
31
|
+
* generations without paying for N downloads (or N uploads).
|
|
32
|
+
*/
|
|
33
|
+
export async function resolveImageInputs(args, client) {
|
|
34
|
+
const report = {};
|
|
35
|
+
const inputs = [];
|
|
36
|
+
const onDisk = client.mediaSink?.persistsFiles ?? true;
|
|
37
|
+
// 1. clipboard (macOS, stdio only). Imported dynamically so `child_process`
|
|
38
|
+
// stays off the default path — and off the Worker's module graph entirely.
|
|
39
|
+
if (args.from_clipboard) {
|
|
40
|
+
inputs.push(await (await import('./clipboard.js')).readClipboardImage());
|
|
41
|
+
}
|
|
42
|
+
// 2. local paths — inline, unless this session has seen the same file before.
|
|
43
|
+
for (const path of args.images ?? []) {
|
|
44
|
+
inputs.push(await resolveLocalPath(path, client, onDisk, report));
|
|
45
|
+
}
|
|
46
|
+
// 3. raw base64 / data URIs
|
|
47
|
+
if (args.images_base64?.length) {
|
|
48
|
+
const { decodeImageInput } = await import('./images.js');
|
|
49
|
+
for (const b64 of args.images_base64)
|
|
50
|
+
inputs.push(decodeImageInput(b64));
|
|
51
|
+
}
|
|
52
|
+
// 4. https URLs, fetched by the server. Deduped within the call.
|
|
53
|
+
const fetched = new Map();
|
|
54
|
+
for (const url of args.images_url ?? []) {
|
|
55
|
+
const hit = fetched.get(url);
|
|
56
|
+
if (hit) {
|
|
57
|
+
inputs.push(hit);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const resolved = await resolveUrl(url, client, report);
|
|
61
|
+
fetched.set(url, resolved);
|
|
62
|
+
inputs.push(resolved);
|
|
63
|
+
}
|
|
64
|
+
// 5. Files API references — no bytes move at all.
|
|
65
|
+
const seenUris = new Map();
|
|
66
|
+
for (const ref of args.images_file_uris ?? []) {
|
|
67
|
+
const hit = seenUris.get(ref);
|
|
68
|
+
if (hit) {
|
|
69
|
+
inputs.push(hit);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const file = await client.getFile(ref);
|
|
73
|
+
const input = { uri: file.uri, mimeType: file.mimeType };
|
|
74
|
+
seenUris.set(ref, input);
|
|
75
|
+
(report.file_uris ??= []).push(file.name);
|
|
76
|
+
inputs.push(input);
|
|
77
|
+
}
|
|
78
|
+
return { inputs, report: Object.keys(report).length > 0 ? report : undefined };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* One local path → an inline input, or a Files API reference once this session
|
|
82
|
+
* has referenced the same bytes more than once.
|
|
83
|
+
*
|
|
84
|
+
* Identity is path + mtime + size, so editing the file in place produces a new
|
|
85
|
+
* key rather than silently reusing an upload of the old content.
|
|
86
|
+
*/
|
|
87
|
+
async function resolveLocalPath(path, client, onDisk, report) {
|
|
88
|
+
const { readImageAsInline, resolveImagePath } = await import('./images.js');
|
|
89
|
+
// A runtime with no filesystem never gets here — `assertLocalInputsAvailable`
|
|
90
|
+
// rejects `images` before any of this — but if it somehow did, reading the
|
|
91
|
+
// file is what would fail, so keep the pre-existing error path.
|
|
92
|
+
if (!onDisk)
|
|
93
|
+
return readImageAsInline(path);
|
|
94
|
+
const { stat } = await import('node:fs/promises');
|
|
95
|
+
const resolved = resolveImagePath(path);
|
|
96
|
+
let key;
|
|
97
|
+
try {
|
|
98
|
+
const { mtimeMs, size } = await stat(resolved);
|
|
99
|
+
// NUL separator, written as the \u0000 ESCAPE and never a literal NUL
|
|
100
|
+
// byte: a literal one makes git classify this whole file as binary and
|
|
101
|
+
// stop diffing it (same trap as fingerprintRequest in jobs.ts). It cannot
|
|
102
|
+
// occur in a path, so no crafted name can straddle a field boundary.
|
|
103
|
+
key = `path\u0000${resolved}\u0000${mtimeMs}\u0000${size}`;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Unstattable but resolvable is odd; fall back to plain inline rather than
|
|
107
|
+
// failing a call that would otherwise have worked.
|
|
108
|
+
return readImageAsInline(path);
|
|
109
|
+
}
|
|
110
|
+
const cached = client.session.cachedUpload(key);
|
|
111
|
+
if (cached)
|
|
112
|
+
return { uri: cached.uri, mimeType: cached.mimeType };
|
|
113
|
+
const references = client.session.countReference(key);
|
|
114
|
+
const inline = await readImageAsInline(resolved);
|
|
115
|
+
if (references < 2)
|
|
116
|
+
return inline;
|
|
117
|
+
// Second sighting: pay one upload now so every later reference is free.
|
|
118
|
+
// Best-effort — an upload failure must not fail a call that inline handles
|
|
119
|
+
// perfectly well.
|
|
120
|
+
try {
|
|
121
|
+
const bytes = base64ToBytes(inline.base64);
|
|
122
|
+
const displayName = resolved.split(/[\\/]/).pop() || 'image';
|
|
123
|
+
const uploaded = await client.uploadBytes(bytes, inline.mimeType, displayName);
|
|
124
|
+
client.session.uploadCache.set(key, {
|
|
125
|
+
name: uploaded.name,
|
|
126
|
+
uri: uploaded.uri,
|
|
127
|
+
mimeType: uploaded.mimeType,
|
|
128
|
+
expiresAtMs: expiresAtMs(uploaded.expirationTime, Date.now()),
|
|
129
|
+
});
|
|
130
|
+
(report.auto_uploaded ??= []).push({ path: resolved, file_uri: uploaded.name, expires: uploaded.expirationTime });
|
|
131
|
+
return { uri: uploaded.uri, mimeType: uploaded.mimeType };
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return inline;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** One https URL → inline bytes, or a Files API reference when it's large. */
|
|
138
|
+
async function resolveUrl(url, client, report) {
|
|
139
|
+
const fetched = await client.fetchRemoteImage(url);
|
|
140
|
+
const entry = {
|
|
141
|
+
url,
|
|
142
|
+
mime_type: fetched.mimeType,
|
|
143
|
+
bytes: fetched.size,
|
|
144
|
+
};
|
|
145
|
+
(report.fetched_urls ??= []).push(entry);
|
|
146
|
+
if (fetched.size <= IMAGE_INLINE_MAX_BYTES) {
|
|
147
|
+
return { base64: bytesToBase64(fetched.bytes), mimeType: fetched.mimeType };
|
|
148
|
+
}
|
|
149
|
+
// 'image' here: this path is `images_url`, which only ever carries images.
|
|
150
|
+
const displayName = fileNameFromUrl(url, 'image');
|
|
151
|
+
const uploaded = await client.uploadBytes(fetched.bytes, fetched.mimeType, displayName);
|
|
152
|
+
entry.file_uri = uploaded.name;
|
|
153
|
+
return { uri: uploaded.uri, mimeType: uploaded.mimeType };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Guard for tools that REQUIRE at least one reference image
|
|
157
|
+
* (`gemini_image_edit`). Names every accepted param, in the order a caller
|
|
158
|
+
* should prefer them — the context-free ones first.
|
|
159
|
+
*/
|
|
160
|
+
export function requireImageInput(args) {
|
|
161
|
+
if (hasImageInput(args))
|
|
162
|
+
return;
|
|
163
|
+
throw new McpToolError('Provide at least one input image via `images_url` (an https URL the server fetches), `images_file_uris` ' +
|
|
164
|
+
'(from gemini_upload_file), `images` (a local path), `images_base64`, or `from_clipboard`.');
|
|
165
|
+
}
|
package/dist/session.js
CHANGED
|
@@ -44,6 +44,43 @@ export class SessionState {
|
|
|
44
44
|
* model against the requested edit.
|
|
45
45
|
*/
|
|
46
46
|
writtenOutputs = new Set();
|
|
47
|
+
/**
|
|
48
|
+
* Files API uploads this session can reuse, keyed by an opaque identity
|
|
49
|
+
* string (for a local path: absolute path + mtime + size, so an edited file
|
|
50
|
+
* is a different key rather than a stale hit).
|
|
51
|
+
*
|
|
52
|
+
* Backs the stdio "referenced twice → upload once" optimization: the second
|
|
53
|
+
* time a session sends the same picture, the bytes are replaced by a
|
|
54
|
+
* `files/…` reference that costs nothing to repeat. Session-scoped, like
|
|
55
|
+
* everything else here — a uri is minted by ONE user's API key and is only
|
|
56
|
+
* readable with that key, so sharing this map across the connector's tenants
|
|
57
|
+
* would hand out references another user cannot use and this user did not
|
|
58
|
+
* create.
|
|
59
|
+
*/
|
|
60
|
+
uploadCache = new Map();
|
|
61
|
+
/**
|
|
62
|
+
* How many times each identity has been referenced this session. The
|
|
63
|
+
* threshold for auto-uploading is "more than once", so the count has to
|
|
64
|
+
* outlive the call that first saw it.
|
|
65
|
+
*/
|
|
66
|
+
referenceCounts = new Map();
|
|
67
|
+
/** Record a reference and return the running total (1 on first sight). */
|
|
68
|
+
countReference(key) {
|
|
69
|
+
const next = (this.referenceCounts.get(key) ?? 0) + 1;
|
|
70
|
+
this.referenceCounts.set(key, next);
|
|
71
|
+
return next;
|
|
72
|
+
}
|
|
73
|
+
/** A cached upload, or `undefined` if absent or past its Files API TTL. */
|
|
74
|
+
cachedUpload(key, now = Date.now()) {
|
|
75
|
+
const hit = this.uploadCache.get(key);
|
|
76
|
+
if (!hit)
|
|
77
|
+
return undefined;
|
|
78
|
+
if (hit.expiresAtMs <= now) {
|
|
79
|
+
this.uploadCache.delete(key);
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
return hit;
|
|
83
|
+
}
|
|
47
84
|
/** Forget everything. Tests use this; nothing in `src/` calls it. */
|
|
48
85
|
reset() {
|
|
49
86
|
this.jobs.reset();
|
|
@@ -51,5 +88,7 @@ export class SessionState {
|
|
|
51
88
|
this.lastMusicInteractionId = undefined;
|
|
52
89
|
this.lastVideoInteractionId = undefined;
|
|
53
90
|
this.writtenOutputs.clear();
|
|
91
|
+
this.uploadCache.clear();
|
|
92
|
+
this.referenceCounts.clear();
|
|
54
93
|
}
|
|
55
94
|
}
|
package/dist/storage/media.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* is a thin wrapper over the pre-existing `resolveOutputDir` + `writeMedia`
|
|
12
12
|
* behaviour and must stay byte-for-byte equivalent to it.
|
|
13
13
|
*/
|
|
14
|
+
import { base64ToBytes } from '../bytes.js';
|
|
14
15
|
/**
|
|
15
16
|
* The stdio sink: `resolveOutputDir(output_dir)` → `writeMedia` per item, in
|
|
16
17
|
* order (so `uniquePath`'s `name`/`name-2`/`name-3` sequencing is preserved).
|
|
@@ -69,7 +70,7 @@ export function createR2Sink(bucket, opts) {
|
|
|
69
70
|
const refs = [];
|
|
70
71
|
for (const it of items) {
|
|
71
72
|
const key = `${prefix}/${day}/${randomId()}-${safeKeySegment(it.base)}.${mediaExt(it.mimeType)}`;
|
|
72
|
-
const bytes =
|
|
73
|
+
const bytes = base64ToBytes(it.base64);
|
|
73
74
|
await bucket.put(key, bytes, { httpMetadata: { contentType: it.mimeType } });
|
|
74
75
|
refs.push(base ? `${base}/${key}` : `r2://${opts.bucketName ?? 'media'}/${key}`);
|
|
75
76
|
}
|
|
@@ -83,15 +84,3 @@ export function createR2Sink(bucket, opts) {
|
|
|
83
84
|
'This hosted connector has no filesystem, so `output_dir` is ignored and no <image>.json sidecar is written — capture interaction_id from this result to chain further turns.',
|
|
84
85
|
};
|
|
85
86
|
}
|
|
86
|
-
/**
|
|
87
|
-
* base64 → bytes without `Buffer`. `Buffer` exists in a Worker under
|
|
88
|
-
* `nodejs_compat`, but `atob` is the platform primitive on both runtimes and
|
|
89
|
-
* keeps this module free of any Node dependency.
|
|
90
|
-
*/
|
|
91
|
-
function decodeBase64(b64) {
|
|
92
|
-
const binary = atob(b64);
|
|
93
|
-
const bytes = new Uint8Array(binary.length);
|
|
94
|
-
for (let i = 0; i < binary.length; i++)
|
|
95
|
-
bytes[i] = binary.charCodeAt(i);
|
|
96
|
-
return bytes;
|
|
97
|
-
}
|