@chrischall/gemini-mcp 1.1.0 → 1.3.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.
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Tell an MCP session which hostname it was reached on.
3
+ *
4
+ * This is the single load-bearing link in the zero-config media-URL path: the
5
+ * origin captured here becomes `https://<host>/media/…` for every generated
6
+ * image. If it goes missing, generations silently stop coming back with
7
+ * openable links — the exact failure the media work exists to end — so it lives
8
+ * in its own module, free of any Cloudflare import, purely so it can be tested.
9
+ * (`src/worker.ts` pulls in `agents` and cannot load under Node at all.)
10
+ *
11
+ * Deriving the origin per request rather than configuring it is what makes a
12
+ * fork, a `*.workers.dev` preview and the custom domain all mint correct links
13
+ * with nothing to set. It rides on `ctx.props` — the object the OAuth provider
14
+ * has already populated with the decrypted grant — rather than a module-level
15
+ * variable, because one isolate serves many authenticated sessions (session.ts).
16
+ */
17
+ /** Property name the session reads back off its props. */
18
+ export const CONNECTOR_ORIGIN_PROP = 'connectorOrigin';
19
+ /**
20
+ * Wrap a handler so `ctx.props.connectorOrigin` is set before it runs.
21
+ *
22
+ * Existing props are preserved — they carry the user's API key, and dropping
23
+ * them would break authentication rather than merely lose a URL.
24
+ */
25
+ export function withConnectorOrigin(inner) {
26
+ return {
27
+ fetch(request, env, ctx) {
28
+ const carrier = (ctx ?? {});
29
+ const existing = (typeof carrier.props === 'object' && carrier.props !== null ? carrier.props : {});
30
+ carrier.props = { ...existing, [CONNECTOR_ORIGIN_PROP]: new URL(request.url).origin };
31
+ return inner.fetch(request, env, ctx);
32
+ },
33
+ };
34
+ }
@@ -1,4 +1,5 @@
1
1
  import { McpToolError } from '@chrischall/mcp-utils';
2
+ import { formatMb } from './bytes.js';
2
3
  /**
3
4
  * Server-side fetching of `images_url` references.
4
5
  *
@@ -207,6 +208,28 @@ function assertPublicHttpsUrl(raw, requested) {
207
208
  }
208
209
  return url;
209
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
+ }
210
233
  /** "<url>" — or "<url> (redirected from <requested>)" when they differ. */
211
234
  function describeUrl(current, requested) {
212
235
  return current === requested ? current : `${current} (redirected from ${requested})`;
@@ -265,8 +288,7 @@ function fetchFailure(err, current, requested, timeoutMs) {
265
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.' });
266
289
  }
267
290
  function tooBig(url, requested, size, max, partial = false) {
268
- const mb = (n) => `${(n / (1024 * 1024)).toFixed(1)} MB`;
269
- return new McpToolError(`Image at ${describeUrl(url, requested)} is ${partial ? 'over' : ''} ${mb(size)}, above the ${mb(max)} limit for images_url.`, { hint: 'Resize or re-encode the image, or upload it yourself with gemini_upload_file and pass the returned file_uri.' });
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.' });
270
292
  }
271
293
  /**
272
294
  * Fetch one image URL server-side, validating protocol, destination, response
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/inputs.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { McpToolError } from '@chrischall/mcp-utils';
2
- import { IMAGE_INLINE_MAX_BYTES } from './fetch-image.js';
2
+ import { IMAGE_INLINE_MAX_BYTES, fileNameFromUrl } from './fetch-image.js';
3
+ import { base64ToBytes, bytesToBase64 } from './bytes.js';
3
4
  /** True when this call carries at least one image input of any kind. */
4
5
  export function hasImageInput(args) {
5
6
  return Boolean(args.images?.length ||
@@ -117,7 +118,7 @@ async function resolveLocalPath(path, client, onDisk, report) {
117
118
  // Best-effort — an upload failure must not fail a call that inline handles
118
119
  // perfectly well.
119
120
  try {
120
- const bytes = Uint8Array.from(atob(inline.base64), (c) => c.charCodeAt(0));
121
+ const bytes = base64ToBytes(inline.base64);
121
122
  const displayName = resolved.split(/[\\/]/).pop() || 'image';
122
123
  const uploaded = await client.uploadBytes(bytes, inline.mimeType, displayName);
123
124
  client.session.uploadCache.set(key, {
@@ -143,36 +144,14 @@ async function resolveUrl(url, client, report) {
143
144
  };
144
145
  (report.fetched_urls ??= []).push(entry);
145
146
  if (fetched.size <= IMAGE_INLINE_MAX_BYTES) {
146
- return { base64: encodeBase64(fetched.bytes), mimeType: fetched.mimeType };
147
+ return { base64: bytesToBase64(fetched.bytes), mimeType: fetched.mimeType };
147
148
  }
148
- const displayName = fileNameFromUrl(url);
149
+ // 'image' here: this path is `images_url`, which only ever carries images.
150
+ const displayName = fileNameFromUrl(url, 'image');
149
151
  const uploaded = await client.uploadBytes(fetched.bytes, fetched.mimeType, displayName);
150
152
  entry.file_uri = uploaded.name;
151
153
  return { uri: uploaded.uri, mimeType: uploaded.mimeType };
152
154
  }
153
- /** Last path segment of a URL, for the Files API display name. */
154
- function fileNameFromUrl(url) {
155
- try {
156
- const name = new URL(url).pathname.split('/').filter(Boolean).pop();
157
- return name ? decodeURIComponent(name).slice(0, 120) : 'image';
158
- }
159
- catch {
160
- return 'image';
161
- }
162
- }
163
- /**
164
- * bytes → base64 without `Buffer`. `btoa` is the primitive both runtimes share
165
- * (see `storage/media.ts`, which decodes the same way); chunked because
166
- * `String.fromCharCode(...huge)` blows the argument limit.
167
- */
168
- function encodeBase64(bytes) {
169
- let binary = '';
170
- const CHUNK = 0x8000;
171
- for (let i = 0; i < bytes.length; i += CHUNK) {
172
- binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
173
- }
174
- return btoa(binary);
175
- }
176
155
  /**
177
156
  * Guard for tools that REQUIRE at least one reference image
178
157
  * (`gemini_image_edit`). Names every accepted param, in the order a caller
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Retention for generated media.
3
+ *
4
+ * Every hosted generation writes an object to R2 and nothing ever removed it,
5
+ * so the bucket grew without bound — and the signed links pointing into it are
6
+ * only useful for a couple of days anyway. A scheduled sweep keeps the two in
7
+ * step: objects live `MEDIA_TTL_DAYS` (default 7), and a signed URL's lifetime
8
+ * is clamped to that, so a link never outlives the object behind it.
9
+ *
10
+ * R2 lifecycle rules would be the cheaper mechanism, but they are configured on
11
+ * the bucket rather than in `wrangler.jsonc`, so a fresh deploy of this repo
12
+ * would silently have no retention at all. A cron in the Worker ships with the
13
+ * code.
14
+ */
15
+ const DEFAULT_MAX_OBJECTS = 5_000;
16
+ /**
17
+ * `gen/` is where media is written now; `media/` is where it was written before
18
+ * the rename that made URLs read `/media/gen/…` instead of `/media/media/…`.
19
+ * Both are swept — retention that ignores the existing backlog is not retention.
20
+ */
21
+ const LEGACY_AND_CURRENT_PREFIXES = ['gen/', 'media/'];
22
+ /** R2 accepts bulk deletes in batches; 1000 is the documented ceiling. */
23
+ const DELETE_BATCH = 1_000;
24
+ /** Days → ms, defaulting to 7 for anything missing or nonsensical. */
25
+ export function retentionMsFromDays(days) {
26
+ const n = Number(days);
27
+ return (Number.isFinite(n) && n > 0 ? n : 7) * 24 * 60 * 60 * 1000;
28
+ }
29
+ /**
30
+ * Delete generated media older than `retentionMs`. Returns counts rather than
31
+ * logging them, so the caller decides what to do with the numbers and the sweep
32
+ * itself stays testable.
33
+ */
34
+ export async function cleanupExpiredMedia(bucket, retentionMs, opts = {}) {
35
+ const now = opts.now?.() ?? Date.now();
36
+ const cutoff = now - retentionMs;
37
+ const maxObjects = opts.maxObjects ?? DEFAULT_MAX_OBJECTS;
38
+ const prefixes = opts.prefixes ?? LEGACY_AND_CURRENT_PREFIXES;
39
+ let scanned = 0;
40
+ let deleted = 0;
41
+ let truncated = false;
42
+ const doomed = [];
43
+ const flush = async (all) => {
44
+ while (doomed.length >= (all ? 1 : DELETE_BATCH)) {
45
+ const batch = doomed.splice(0, DELETE_BATCH);
46
+ await bucket.delete(batch);
47
+ deleted += batch.length;
48
+ }
49
+ };
50
+ for (const prefix of prefixes) {
51
+ let cursor;
52
+ for (;;) {
53
+ if (scanned >= maxObjects) {
54
+ truncated = true;
55
+ break;
56
+ }
57
+ const page = await bucket.list({ prefix, cursor, limit: Math.min(1000, maxObjects - scanned) });
58
+ for (const object of page.objects) {
59
+ scanned++;
60
+ if (object.uploaded.getTime() < cutoff)
61
+ doomed.push(object.key);
62
+ }
63
+ await flush(false);
64
+ if (!page.truncated)
65
+ break;
66
+ if (scanned >= maxObjects) {
67
+ truncated = true;
68
+ break;
69
+ }
70
+ cursor = page.cursor;
71
+ }
72
+ }
73
+ await flush(true);
74
+ return { scanned, deleted, truncated };
75
+ }
@@ -0,0 +1,72 @@
1
+ import { loadSigningKey, verifyMediaSignature } from './media-url.js';
2
+ function problem(status, message) {
3
+ return new Response(`${message}\n`, {
4
+ status,
5
+ headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' },
6
+ });
7
+ }
8
+ /**
9
+ * Build the `/media` handler. `basePath` is stripped from the pathname to get
10
+ * the R2 key, so the key keeps its own prefix and the two never drift.
11
+ */
12
+ export function createMediaHandler(env, basePath = '/media/') {
13
+ return async function handleMedia(request) {
14
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
15
+ return problem(405, `Method ${request.method} not allowed; media links are GET-only.`);
16
+ }
17
+ const url = new URL(request.url);
18
+ if (!url.pathname.startsWith(basePath))
19
+ return problem(404, 'Not found');
20
+ // decodeURIComponent per segment, mirroring how the URL was built. A key
21
+ // cannot contain `..` (the sink sanitises every segment), and R2 has no
22
+ // path traversal to exploit anyway — a key is an opaque string, not a path.
23
+ const key = url.pathname
24
+ .slice(basePath.length)
25
+ .split('/')
26
+ .map((segment) => {
27
+ try {
28
+ return decodeURIComponent(segment);
29
+ }
30
+ catch {
31
+ return segment;
32
+ }
33
+ })
34
+ .join('/');
35
+ if (!key)
36
+ return problem(404, 'Not found');
37
+ const now = env.now?.() ?? Date.now();
38
+ // Read-only: verification must never CREATE a signing secret. A stale KV
39
+ // read here would otherwise clobber the secret every outstanding link was
40
+ // signed with (see loadSigningKey). No secret ⇒ nothing was legitimately
41
+ // signed ⇒ refuse, without touching storage or the bucket.
42
+ const signingKey = await loadSigningKey(env.secret, env.store);
43
+ const check = signingKey
44
+ ? await verifyMediaSignature(signingKey, key, url.searchParams.get('exp'), url.searchParams.get('sig'), now)
45
+ : { ok: false, reason: 'invalid' };
46
+ if (!check.ok) {
47
+ if (check.reason === 'expired') {
48
+ return problem(410, 'This media link has expired. Generated media is retained for a limited time — re-run the generation to get a fresh link.');
49
+ }
50
+ return problem(403, 'Invalid or missing media signature. Media links must be used exactly as returned by the tool, including the exp and sig parameters.');
51
+ }
52
+ const object = await env.bucket.get(key);
53
+ if (!object) {
54
+ return problem(404, 'That media object no longer exists — it may have been cleaned up. Re-run the generation to get a fresh one.');
55
+ }
56
+ const headers = new Headers({
57
+ 'content-type': object.httpMetadata?.contentType ?? 'application/octet-stream',
58
+ // The URL is signed and expiring, so it is safe to cache for its lifetime;
59
+ // `private` keeps shared caches from serving it to anyone else.
60
+ 'cache-control': 'private, max-age=3600',
61
+ 'content-disposition': `inline; filename="${key.split('/').pop() ?? 'media'}"`,
62
+ // The bytes are model output being handed to a browser — never let a
63
+ // sniffed content type turn an image response into something executable.
64
+ 'x-content-type-options': 'nosniff',
65
+ });
66
+ if (object.size !== undefined)
67
+ headers.set('content-length', String(object.size));
68
+ if (request.method === 'HEAD')
69
+ return new Response(null, { status: 200, headers });
70
+ return new Response(object.body, { status: 200, headers });
71
+ };
72
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Signed URLs for generated media on the hosted connector.
3
+ *
4
+ * The problem these exist to solve: a chat client shows the user whatever the
5
+ * assistant *says*, and MCP inline image blocks are not that. On claude.ai and
6
+ * the mobile app the bytes arrive in the model's context and are never rendered
7
+ * — so a generation that billed successfully is invisible. A URL is the only
8
+ * portable answer: a human can tap it and an agent can curl it.
9
+ *
10
+ * That means the media route cannot sit behind the connector's OAuth, because
11
+ * a browser opening a link carries no bearer token. The signature IS the
12
+ * authorization: a URL is valid for one key until it expires, and nothing else
13
+ * in the bucket is reachable without one.
14
+ *
15
+ * Kept free of any Cloudflare import so the whole thing is testable under Node:
16
+ * WebCrypto is the only primitive used, and it exists on both runtimes.
17
+ */
18
+ /** How long a freshly minted media URL stays valid. Matches Files API retention. */
19
+ export const MEDIA_URL_TTL_MS = 48 * 60 * 60 * 1000;
20
+ /** KV key holding the lazily-generated signing secret (zero-config path). */
21
+ export const SIGNING_SECRET_KV_KEY = 'media:url-signing-secret';
22
+ function base64UrlEncode(bytes) {
23
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
24
+ let binary = '';
25
+ for (let i = 0; i < view.length; i++)
26
+ binary += String.fromCharCode(view[i]);
27
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
28
+ }
29
+ function base64UrlDecode(text) {
30
+ const padded = text.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(text.length / 4) * 4, '=');
31
+ const binary = atob(padded);
32
+ const out = new Uint8Array(binary.length);
33
+ for (let i = 0; i < binary.length; i++)
34
+ out[i] = binary.charCodeAt(i);
35
+ return out;
36
+ }
37
+ /** Import raw secret material as an HMAC key. */
38
+ async function importKey(material) {
39
+ return crypto.subtle.importKey('raw', new TextEncoder().encode(material), { name: 'HMAC', hash: 'SHA-256' }, false, [
40
+ 'sign',
41
+ 'verify',
42
+ ]);
43
+ }
44
+ /**
45
+ * The HMAC key for **signing**, creating one if this deployment has never
46
+ * signed anything.
47
+ *
48
+ * `MEDIA_URL_SECRET` wins when an operator sets one. Otherwise a random 32-byte
49
+ * secret is generated once and persisted in KV — which is what makes the whole
50
+ * feature work with **zero configuration**, the entire point of this change.
51
+ *
52
+ * Creating is confined to this function, and this function is called only when
53
+ * minting a URL. {@link loadSigningKey} is what the verify path uses. That
54
+ * split is load-bearing, not stylistic — see the warning there.
55
+ *
56
+ * Deliberately NOT cached in a module-level variable: `src/` holds no
57
+ * module-scope mutable state (see session.ts), and a KV read per sign is cheap
58
+ * next to an image generation. There is a narrow first-use race — two isolates
59
+ * generating simultaneously — so the value is re-read after writing and the
60
+ * stored one wins, collapsing the window to URLs minted in that same instant.
61
+ */
62
+ export async function resolveSigningKey(secret, store) {
63
+ let material = secret?.trim();
64
+ if (!material) {
65
+ material = (await store.get(SIGNING_SECRET_KV_KEY)) ?? undefined;
66
+ if (!material) {
67
+ const generated = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
68
+ await store.put(SIGNING_SECRET_KV_KEY, generated);
69
+ // Re-read so a concurrent generator's value wins consistently for both.
70
+ material = (await store.get(SIGNING_SECRET_KV_KEY)) ?? generated;
71
+ }
72
+ }
73
+ return importKey(material);
74
+ }
75
+ /**
76
+ * The HMAC key for **verifying**. Read-only: returns `undefined` when no secret
77
+ * is stored, and never creates one.
78
+ *
79
+ * This is not a stylistic split. KV is eventually consistent — a `put` on one
80
+ * isolate is not immediately visible to another — so the verify path can and
81
+ * will see a miss for a secret that exists. If verification created-on-miss
82
+ * (as it did originally), that transient miss would mint a NEW secret and
83
+ * overwrite the one every outstanding link was signed with, permanently 403ing
84
+ * media that had been signed perfectly correctly. A self-inflicted denial of
85
+ * service, triggered by ordinary traffic, and irreversible once the old secret
86
+ * is gone.
87
+ *
88
+ * No secret stored ⇒ nothing was ever legitimately signed ⇒ refuse.
89
+ */
90
+ export async function loadSigningKey(secret, store) {
91
+ const material = secret?.trim() || (await store.get(SIGNING_SECRET_KV_KEY));
92
+ return material ? importKey(material) : undefined;
93
+ }
94
+ /** The bytes covered by a signature: the object key and the expiry, nothing else. */
95
+ function payload(key, expiresAtMs) {
96
+ return new TextEncoder().encode(`${key}\n${expiresAtMs}`);
97
+ }
98
+ /** Sign one object key for use until `expiresAtMs`. */
99
+ export async function signMediaKey(signingKey, key, expiresAtMs) {
100
+ return base64UrlEncode(await crypto.subtle.sign('HMAC', signingKey, payload(key, expiresAtMs)));
101
+ }
102
+ /**
103
+ * Check a `?exp=&sig=` pair against an object key.
104
+ *
105
+ * Verification goes through `crypto.subtle.verify` rather than a string
106
+ * comparison, so it is constant-time — a `===` on the signature would leak the
107
+ * expected value one byte at a time to anyone willing to time it.
108
+ */
109
+ export async function verifyMediaSignature(signingKey, key, exp, sig, nowMs) {
110
+ if (!exp || !sig)
111
+ return { ok: false, reason: 'missing' };
112
+ const expiresAtMs = Number(exp);
113
+ if (!Number.isFinite(expiresAtMs))
114
+ return { ok: false, reason: 'invalid' };
115
+ let valid;
116
+ try {
117
+ valid = await crypto.subtle.verify('HMAC', signingKey, base64UrlDecode(sig), payload(key, expiresAtMs));
118
+ }
119
+ catch {
120
+ return { ok: false, reason: 'invalid' };
121
+ }
122
+ // Signature first, then expiry: an expired-but-authentic link deserves a
123
+ // different message from a forged one, and saying "expired" about a forgery
124
+ // would confirm the attacker got the signature right.
125
+ if (!valid)
126
+ return { ok: false, reason: 'invalid' };
127
+ if (nowMs > expiresAtMs)
128
+ return { ok: false, reason: 'expired' };
129
+ return { ok: true };
130
+ }
131
+ /** `<base>/<key>?exp=…&sig=…` — the URL handed back to the caller. */
132
+ export function buildMediaUrl(baseUrl, key, expiresAtMs, signature) {
133
+ const path = key.split('/').map(encodeURIComponent).join('/');
134
+ return `${baseUrl.replace(/\/+$/, '')}/${path}?exp=${expiresAtMs}&sig=${signature}`;
135
+ }
@@ -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).
@@ -29,13 +30,15 @@ export function createDiskSink() {
29
30
  // Sequential on purpose: uniquePath checks the filesystem, so writing two
30
31
  // colliding names concurrently would race onto the same path.
31
32
  for (const it of items)
32
- refs.push(await writeMedia(dir, it.base, it.base64, it.mimeType));
33
+ refs.push({ ref: await writeMedia(dir, it.base, it.base64, it.mimeType) });
33
34
  return refs;
34
35
  },
35
36
  note: () => undefined,
36
37
  };
37
38
  }
38
- const KEY_PREFIX_DEFAULT = 'media';
39
+ // `gen`, not `media`: the objects are served at `/media/<key>`, and a `media`
40
+ // prefix would make every URL read `/media/media/…`.
41
+ const KEY_PREFIX_DEFAULT = 'gen';
39
42
  /** Object-key-safe name: no slashes, no dot-segments, never empty. */
40
43
  function safeKeySegment(base) {
41
44
  const cleaned = base
@@ -56,7 +59,9 @@ export function createR2Sink(bucket, opts) {
56
59
  const prefix = opts.prefix ?? KEY_PREFIX_DEFAULT;
57
60
  const now = opts.now ?? (() => new Date());
58
61
  const randomId = opts.randomId ?? (() => crypto.randomUUID().slice(0, 8));
59
- const base = opts.publicBaseUrl?.replace(/\/+$/, '');
62
+ const publicBase = opts.publicBaseUrl?.replace(/\/+$/, '');
63
+ const signedBase = opts.signedBaseUrl?.replace(/\/+$/, '');
64
+ const ttlMs = opts.urlTtlMs ?? 0;
60
65
  return {
61
66
  kind: 'r2',
62
67
  persistsFiles: false,
@@ -66,32 +71,38 @@ export function createR2Sink(bucket, opts) {
66
71
  // module graph.
67
72
  const { mediaExt } = await import('../images.js');
68
73
  const day = now().toISOString().slice(0, 10);
74
+ const expiresAtMs = now().getTime() + ttlMs;
69
75
  const refs = [];
70
76
  for (const it of items) {
71
77
  const key = `${prefix}/${day}/${randomId()}-${safeKeySegment(it.base)}.${mediaExt(it.mimeType)}`;
72
- const bytes = decodeBase64(it.base64);
73
- await bucket.put(key, bytes, { httpMetadata: { contentType: it.mimeType } });
74
- refs.push(base ? `${base}/${key}` : `r2://${opts.bucketName ?? 'media'}/${key}`);
78
+ await bucket.put(key, base64ToBytes(it.base64), { httpMetadata: { contentType: it.mimeType } });
79
+ refs.push(await describe(key, expiresAtMs));
75
80
  }
76
81
  return refs;
77
82
  },
78
- note: () => base
79
- ? 'Generated media was uploaded to R2; the values above are public URLs, not local file paths. ' +
83
+ note: () => publicBase
84
+ ? 'Generated media was uploaded to R2 and the values above are public URLs (not local file paths) — open or download them directly. ' +
80
85
  '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.'
81
- : 'Generated media was uploaded to R2, which is not publicly served: the values above are object refs (r2://bucket/key), NOT fetchable URLs. ' +
82
- 'Set MEDIA_PUBLIC_BASE_URL on the Worker to get URLs, or pass inline: true to receive the bytes directly. ' +
83
- '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.',
86
+ : signedBase
87
+ ? 'Generated media is served by this connector at the signed URLs above — open them in a browser or fetch them with curl; no auth header is needed, the signature is in the link. ' +
88
+ 'They expire (see media[].expires_at), and the objects behind them are cleaned up on a retention schedule. ' +
89
+ '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.'
90
+ : '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. ' +
91
+ 'Set MEDIA_PUBLIC_BASE_URL on the Worker, or pass inline: true to receive the bytes directly.',
84
92
  };
85
- }
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;
93
+ /** One stored object → the ref the caller sees, plus its key and expiry. */
94
+ async function describe(key, expiresAtMs) {
95
+ // A bucket served directly (r2.dev or a custom domain) needs no signature.
96
+ if (publicBase)
97
+ return { ref: `${publicBase}/${key}`, key };
98
+ if (signedBase && opts.sign) {
99
+ const { buildMediaUrl } = await import('../media-url.js');
100
+ const signature = await opts.sign(key, expiresAtMs);
101
+ return { ref: buildMediaUrl(signedBase, key, expiresAtMs, signature), key, expiresAt: new Date(expiresAtMs).toISOString() };
102
+ }
103
+ // Neither configured. Unreachable in the Worker (which always passes its
104
+ // own origin), so this is a misconfiguration rather than a mode — say so
105
+ // explicitly rather than returning something that looks like a filename.
106
+ return { ref: key, key, unavailable: true };
107
+ }
97
108
  }
@@ -1,6 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import { McpToolError, textResult } from '@chrischall/mcp-utils';
3
3
  import { withProgressHeartbeat } from './shared.js';
4
+ import { base64ToBytes, wholeMb } from '../bytes.js';
5
+ import { fileNameFromUrl } from '../fetch-image.js';
4
6
  import { previewUnlessConfirmed, previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
5
7
  /**
6
8
  * First-class Gemini Files API access.
@@ -38,7 +40,6 @@ import { previewUnlessConfirmed, previewLocalInputsUnlessConfirmed, schemaConfir
38
40
  */
39
41
  const UPLOAD_URL_MAX_BYTES_DISK = 100 * 1024 * 1024;
40
42
  const UPLOAD_URL_MAX_BYTES_HOSTED = 25 * 1024 * 1024;
41
- const mb = (bytes) => Math.round(bytes / (1024 * 1024));
42
43
  /** What a `url` upload will accept: the media the Files API is useful for. */
43
44
  const UPLOAD_ACCEPT = ['image/', 'video/', 'audio/'];
44
45
  const RETENTION_NOTE = 'Gemini retains uploaded files for ~48h. Until then this reference can be reused across any number of calls ' +
@@ -62,7 +63,10 @@ export function registerFileTools(server, client) {
62
63
  const onDisk = client.mediaSink?.persistsFiles ?? true;
63
64
  const urlMaxBytes = onDisk ? UPLOAD_URL_MAX_BYTES_DISK : UPLOAD_URL_MAX_BYTES_HOSTED;
64
65
  server.registerTool('gemini_upload_file', {
65
- description: 'Upload an image (or video/audio) to the Gemini Files API ONCE and get back a reusable `files/<id>` reference. ' +
66
+ description: 'Upload a file — an image, reference photo, picture, screenshot, video or audio clip — to the Gemini Files API ' +
67
+ 'ONCE, and get back a reusable `file_uri` (`files/<id>`) to attach to later image, video or music generations. ' +
68
+ 'Keywords: upload, upload file, upload image, upload photo, attach, reference image, reference photo, ' +
69
+ 'file_uri, files api, image reference, reuse across calls. ' +
66
70
  'Use this instead of pasting base64 into a tool call: the reference is a short string, so no image bytes ever ' +
67
71
  'enter the conversation, and it can be reused across many generations until it expires (~48h). ' +
68
72
  'Provide exactly one of `url` (the server downloads it), `data_base64`' +
@@ -77,7 +81,7 @@ export function registerFileTools(server, client) {
77
81
  .string()
78
82
  .url()
79
83
  .optional()
80
- .describe(`Public https URL the SERVER downloads and uploads (image/video/audio, up to ${mb(urlMaxBytes)}MB). ` +
84
+ .describe(`Public https URL the SERVER downloads and uploads (image/video/audio, up to ${wholeMb(urlMaxBytes)}MB). ` +
81
85
  'No bytes pass through the conversation.' +
82
86
  (onDisk ? '' : ' Larger files: POST the raw bytes to /upload, which streams instead of buffering.')),
83
87
  data_base64: z
@@ -122,24 +126,27 @@ export function registerFileTools(server, client) {
122
126
  const uploaded = await withProgressHeartbeat(extra, 'Uploading to the Gemini Files API', async () => {
123
127
  if (args.url) {
124
128
  const fetched = await client.fetchRemoteImage(args.url, { maxBytes: urlMaxBytes, accept: UPLOAD_ACCEPT });
125
- return client.uploadBytes(fetched.bytes, args.mime_type ?? fetched.mimeType, args.display_name ?? nameFromUrl(args.url));
129
+ return client.uploadBytes(fetched.bytes, args.mime_type ?? fetched.mimeType,
130
+ // 'upload', matching the data_base64 and path branches below —
131
+ // this tool takes video and audio too, so 'image' would be a lie.
132
+ args.display_name ?? fileNameFromUrl(args.url, 'upload'));
126
133
  }
127
134
  if (args.data_base64) {
128
135
  const { decodeImageInput } = await import('../images.js');
129
136
  const decoded = decodeImageInput(args.data_base64);
130
- const bytes = Uint8Array.from(atob(decoded.base64), (c) => c.charCodeAt(0));
137
+ const bytes = base64ToBytes(decoded.base64);
131
138
  return client.uploadBytes(bytes, args.mime_type ?? decoded.mimeType, args.display_name ?? 'upload');
132
139
  }
133
140
  const { readImageAsInline, resolveImagePath } = await import('../images.js');
134
141
  const resolved = resolveImagePath(args.path);
135
142
  const inline = await readImageAsInline(resolved);
136
- const bytes = Uint8Array.from(atob(inline.base64), (c) => c.charCodeAt(0));
143
+ const bytes = base64ToBytes(inline.base64);
137
144
  return client.uploadBytes(bytes, args.mime_type ?? inline.mimeType, args.display_name ?? (resolved.split(/[\\/]/).pop() || 'upload'));
138
145
  });
139
146
  return uploadResult(uploaded);
140
147
  });
141
148
  server.registerTool('gemini_list_files', {
142
- description: 'List the files currently uploaded to the Gemini Files API under this API key, with their `files/<id>` references, ' +
149
+ description: 'List files, images and photos currently uploaded to the Gemini Files API under this API key, with their reusable `file_uri` (`files/<id>`) references, ' +
143
150
  'MIME types and expiry times. Retention is ~48h, so an entry that has vanished has expired rather than failed.',
144
151
  annotations: { readOnlyHint: true, openWorldHint: true },
145
152
  inputSchema: {
@@ -165,7 +172,7 @@ export function registerFileTools(server, client) {
165
172
  });
166
173
  });
167
174
  server.registerTool('gemini_delete_file', {
168
- description: 'Delete an uploaded file from the Gemini Files API before its ~48h expiry. Any tool call still referencing it ' +
175
+ description: 'Delete an uploaded file, image or photo (by file_uri) from the Gemini Files API before its ~48h expiry. Any tool call still referencing it ' +
169
176
  'will then fail with a generic 404, so delete only references you are finished with.',
170
177
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
171
178
  inputSchema: {
@@ -182,13 +189,3 @@ export function registerFileTools(server, client) {
182
189
  return textResult({ deleted: args.file_uri });
183
190
  });
184
191
  }
185
- /** Last path segment of a URL, for the Files API display name. */
186
- function nameFromUrl(url) {
187
- try {
188
- const name = new URL(url).pathname.split('/').filter(Boolean).pop();
189
- return name ? decodeURIComponent(name).slice(0, 120) : 'upload';
190
- }
191
- catch {
192
- return 'upload';
193
- }
194
- }