@chrischall/gemini-mcp 1.4.0 → 1.5.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/dist/inputs.js CHANGED
@@ -7,6 +7,7 @@ export function hasImageInput(args) {
7
7
  args.images_base64?.length ||
8
8
  args.images_url?.length ||
9
9
  args.images_file_uris?.length ||
10
+ args.images_r2_keys?.length ||
10
11
  args.from_clipboard);
11
12
  }
12
13
  /** Conservative TTL when the API doesn't tell us: documented retention is ~48h. */
@@ -75,6 +76,40 @@ export async function resolveImageInputs(args, client) {
75
76
  (report.file_uris ??= []).push(file.name);
76
77
  inputs.push(input);
77
78
  }
79
+ // 6. r2_keys — the connector reads its OWN store (tenant-gated), so a signed
80
+ // upload or an earlier generation becomes a reference without a fetch, a
81
+ // signature, or a byte in the conversation. Deduped within the call, same as
82
+ // URLs, and — also same as URLs — a large object is promoted to a Files API
83
+ // reference rather than inlined, because `generateContent` caps a whole
84
+ // request near 20MB and two large inline references would fail it.
85
+ // `readStoredMedia` throws an actionable error for a swept/foreign key.
86
+ const seenKeys = new Map();
87
+ for (const key of args.images_r2_keys ?? []) {
88
+ const hit = seenKeys.get(key);
89
+ if (hit) {
90
+ inputs.push(hit);
91
+ continue;
92
+ }
93
+ const stored = await client.readStoredMedia(key);
94
+ const entry = {
95
+ r2_key: key.trim(),
96
+ mime_type: stored.mimeType,
97
+ bytes: stored.bytes.byteLength,
98
+ };
99
+ (report.r2_keys ??= []).push(entry);
100
+ let input;
101
+ if (stored.bytes.byteLength <= IMAGE_INLINE_MAX_BYTES) {
102
+ input = { base64: bytesToBase64(stored.bytes), mimeType: stored.mimeType };
103
+ }
104
+ else {
105
+ const displayName = key.trim().split('/').pop() || 'image';
106
+ const uploaded = await client.uploadBytes(stored.bytes, stored.mimeType, displayName);
107
+ entry.file_uri = uploaded.name;
108
+ input = { uri: uploaded.uri, mimeType: uploaded.mimeType };
109
+ }
110
+ seenKeys.set(key, input);
111
+ inputs.push(input);
112
+ }
78
113
  return { inputs, report: Object.keys(report).length > 0 ? report : undefined };
79
114
  }
80
115
  /**
@@ -161,5 +196,6 @@ export function requireImageInput(args) {
161
196
  if (hasImageInput(args))
162
197
  return;
163
198
  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`.');
199
+ '(from gemini_upload_file), `images_r2_keys` (a hosted-connector upload or earlier generation), ' +
200
+ '`images` (a local path), `images_base64`, or `from_clipboard`.');
165
201
  }
package/dist/jobs.js CHANGED
@@ -86,6 +86,25 @@ async function refreshMedia(result, sink) {
86
86
  body.media = entries;
87
87
  if (mediaKeyName)
88
88
  body[mediaKeyName] = refs;
89
+ // A multi-image set also carries a bundle zip; its link expires like any
90
+ // other and re-signs from its own r2_key. Best-effort, like the rest.
91
+ const bundle = body.bundle;
92
+ if (bundle && typeof bundle.r2_key === 'string') {
93
+ try {
94
+ const fresh = await sink.resign(bundle.r2_key);
95
+ if (fresh) {
96
+ const { expires_at: _staleBundle, ...rest } = bundle;
97
+ body.bundle = {
98
+ ...rest,
99
+ url: fresh.ref,
100
+ ...(fresh.expiresAt ? { expires_at: fresh.expiresAt } : {}),
101
+ curl_hint: `curl -sS -o ${downloadFilename(fresh.key ?? 'media')} "${fresh.ref}"`,
102
+ };
103
+ body.bundle_url = fresh.ref;
104
+ }
105
+ }
106
+ catch { /* an un-refreshed bundle link is still a valid replay */ }
107
+ }
89
108
  body.media_urls_refreshed = true;
90
109
  return { ...result, content: [{ ...first, text: JSON.stringify(body, null, 2) }, ...(result.content ?? []).slice(1)] };
91
110
  }
@@ -119,6 +138,19 @@ function jobHandle(jobId, status) {
119
138
  hint: `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done" (results are per-process and expire ~10 min after completion).`,
120
139
  });
121
140
  }
141
+ /**
142
+ * Await `promise` for at most `waitMs`; `undefined` means the budget expired
143
+ * with the work still pending (rejections propagate as usual). The timer never
144
+ * holds the process open, and a late rejection after the budget expired is
145
+ * already handled by the registry's own settle handlers.
146
+ */
147
+ function awaitWithBudget(promise, waitMs) {
148
+ return new Promise((resolve, reject) => {
149
+ const timer = setTimeout(() => resolve(undefined), waitMs);
150
+ timer.unref?.();
151
+ promise.then((result) => { clearTimeout(timer); resolve(result); }, (err) => { clearTimeout(timer); reject(err); });
152
+ });
153
+ }
122
154
  /**
123
155
  * One session's jobs. Construct one per session and never share it — see the
124
156
  * cross-tenant note at the top of this file. Reachable as
@@ -181,7 +213,7 @@ export class JobRegistry {
181
213
  * different registry and therefore a genuinely separate generation.
182
214
  */
183
215
  async dispatch(opts, work) {
184
- const { toolName, fingerprint, idempotencyKey, async } = opts;
216
+ const { toolName, fingerprint, idempotencyKey, async, waitMs } = opts;
185
217
  const now = Date.now();
186
218
  let hit;
187
219
  if (idempotencyKey !== undefined) {
@@ -199,10 +231,15 @@ export class JobRegistry {
199
231
  if (hit) {
200
232
  if (async)
201
233
  return jobHandle(hit.jobId, hit.status);
234
+ const settled = waitMs !== undefined && hit.status === 'running'
235
+ ? await awaitWithBudget(hit.promise, waitMs)
236
+ : await hit.promise;
237
+ if (settled === undefined)
238
+ return jobHandle(hit.jobId, 'running');
202
239
  // Refresh before annotating: a recorded result can outlive the signed
203
240
  // URLs inside it, and replaying an expired link is its own kind of wrong
204
241
  // answer — it looks like success.
205
- return annotateReused(await refreshMedia(await hit.promise, this.resigner), hit.jobId);
242
+ return annotateReused(await refreshMedia(settled, this.resigner), hit.jobId);
206
243
  }
207
244
  const jobId = randomUUID();
208
245
  this.evict(now);
@@ -222,6 +259,13 @@ export class JobRegistry {
222
259
  });
223
260
  if (async)
224
261
  return jobHandle(jobId, 'running');
262
+ if (waitMs !== undefined) {
263
+ const settled = await awaitWithBudget(promise, waitMs);
264
+ // Budget expired with the work still running: hand back the job id — the
265
+ // work continues and the caller polls, exactly as with `async: true`,
266
+ // except a fast result would have been returned in-band.
267
+ return settled ?? jobHandle(jobId, 'running');
268
+ }
225
269
  return promise;
226
270
  }
227
271
  /**
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The per-account reference library: named characters (a reference image + a
3
+ * description) and named styles (a reusable prompt fragment, optionally with a
4
+ * reference image of its own).
5
+ *
6
+ * Why it exists: keeping a recurring subject consistent across generations
7
+ * means re-supplying the same reference image and the same description every
8
+ * session — re-uploading the photo, re-typing the description, re-explaining
9
+ * the style. The library makes each of those a NAME: save once, then pass
10
+ * `characters: ["finn"]` / `style: "bold-cartoon-sports"` on any generation.
11
+ *
12
+ * Storage is the connector's R2 bucket under `lib/<tenant>/…`:
13
+ *
14
+ * - `lib/<tenant>/characters/<name>.json` — the record
15
+ * - `lib/<tenant>/characters/<name>.img` — the reference image bytes
16
+ * - `lib/<tenant>/styles/<name>.json` (+ `.img` when a reference was given)
17
+ *
18
+ * Two properties are load-bearing:
19
+ *
20
+ * - **No expiry.** The retention cron sweeps `gen/`, `media/` and `up/` but
21
+ * deliberately never `lib/` (media-cleanup.ts) — a saved character outlives
22
+ * every signed URL and every upload. That is why saving COPIES the image
23
+ * bytes into `lib/` rather than storing an `r2_key` pointer: a pointer into
24
+ * `gen/` or `up/` would dangle after the next sweep.
25
+ * - **Tenant scoping by construction.** Every key this module touches is
26
+ * derived from the session's own tenant id; caller-supplied names are
27
+ * validated against a strict slug charset, so no name can address another
28
+ * account's namespace (or escape `lib/` at all).
29
+ *
30
+ * Worker-safe: no node imports, no module-scope state, structural bucket type.
31
+ */
32
+ /** Key prefix for the library — the one prefix the retention sweep must skip. */
33
+ export const LIBRARY_KEY_PREFIX = 'lib';
34
+ /**
35
+ * Library names are slugs: 1–40 chars, lowercase alphanumerics plus `-`/`_`,
36
+ * starting alphanumeric. Doubles as key-safety (no `/`, `.`, whitespace).
37
+ */
38
+ export const LIBRARY_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/;
39
+ export function isValidLibraryName(name) {
40
+ return LIBRARY_NAME_PATTERN.test(name);
41
+ }
42
+ /** Build the R2-backed library for one session. */
43
+ export function createR2Library(bucket, opts) {
44
+ const now = opts.now ?? (() => new Date());
45
+ let tenantCache;
46
+ const tenantId = () => (tenantCache ??= Promise.resolve(typeof opts.tenant === 'function' ? opts.tenant() : opts.tenant));
47
+ const assertName = (name) => {
48
+ if (!isValidLibraryName(name)) {
49
+ throw new Error(`Invalid library name "${name}" — use 1-40 lowercase letters, digits, "-" or "_" (e.g. "finn", "bold-cartoon-sports").`);
50
+ }
51
+ return name;
52
+ };
53
+ const keyFor = async (kind, name, ext) => `${LIBRARY_KEY_PREFIX}/${await tenantId()}/${kind}/${assertName(name)}.${ext}`;
54
+ async function readJson(key) {
55
+ const object = await bucket.get(key);
56
+ if (!object)
57
+ return undefined;
58
+ try {
59
+ return JSON.parse(new TextDecoder().decode(await object.arrayBuffer()));
60
+ }
61
+ catch {
62
+ // A malformed record is unreadable, not fatal — callers treat it as absent.
63
+ return undefined;
64
+ }
65
+ }
66
+ async function readImage(kind, name) {
67
+ const object = await bucket.get(await keyFor(kind, name, 'img'));
68
+ if (!object)
69
+ return undefined;
70
+ return {
71
+ bytes: new Uint8Array(await object.arrayBuffer()),
72
+ mimeType: object.httpMetadata?.contentType ?? 'application/octet-stream',
73
+ };
74
+ }
75
+ async function listRecords(kind) {
76
+ const prefix = `${LIBRARY_KEY_PREFIX}/${await tenantId()}/${kind}/`;
77
+ const keys = [];
78
+ let cursor;
79
+ for (;;) {
80
+ const page = await bucket.list({ prefix, cursor, limit: 1000 });
81
+ keys.push(...page.objects.map((o) => o.key).filter((k) => k.endsWith('.json')));
82
+ if (!page.truncated)
83
+ break;
84
+ cursor = page.cursor;
85
+ }
86
+ const records = await Promise.all(keys.map((k) => readJson(k)));
87
+ return records
88
+ .filter((r) => r !== undefined)
89
+ .sort((a, b) => a.name.localeCompare(b.name));
90
+ }
91
+ async function deleteEntry(kind, name) {
92
+ const jsonKey = await keyFor(kind, name, 'json');
93
+ const existed = (await bucket.get(jsonKey)) !== null;
94
+ await bucket.delete([jsonKey, await keyFor(kind, name, 'img')]);
95
+ return existed;
96
+ }
97
+ return {
98
+ async saveCharacter({ name, description, image }) {
99
+ const jsonKey = await keyFor('characters', name, 'json');
100
+ const imageKey = await keyFor('characters', name, 'img');
101
+ const at = now().toISOString();
102
+ const existing = await readJson(jsonKey);
103
+ const record = {
104
+ name,
105
+ description,
106
+ image_key: imageKey,
107
+ mime_type: image.mimeType,
108
+ created_at: existing?.created_at ?? at,
109
+ updated_at: at,
110
+ };
111
+ await bucket.put(imageKey, image.bytes, { httpMetadata: { contentType: image.mimeType } });
112
+ await bucket.put(jsonKey, JSON.stringify(record, null, 2), { httpMetadata: { contentType: 'application/json' } });
113
+ return record;
114
+ },
115
+ async getCharacter(name) {
116
+ return readJson(await keyFor('characters', name, 'json'));
117
+ },
118
+ readCharacterImage: (name) => readImage('characters', name),
119
+ listCharacters: () => listRecords('characters'),
120
+ deleteCharacter: (name) => deleteEntry('characters', name),
121
+ async saveStyle({ name, promptFragment, image }) {
122
+ const jsonKey = await keyFor('styles', name, 'json');
123
+ const imageKey = await keyFor('styles', name, 'img');
124
+ const at = now().toISOString();
125
+ const existing = await readJson(jsonKey);
126
+ const record = {
127
+ name,
128
+ prompt_fragment: promptFragment,
129
+ ...(image ? { image_key: imageKey, mime_type: image.mimeType } : {}),
130
+ created_at: existing?.created_at ?? at,
131
+ updated_at: at,
132
+ };
133
+ if (image) {
134
+ await bucket.put(imageKey, image.bytes, { httpMetadata: { contentType: image.mimeType } });
135
+ }
136
+ else if (existing?.image_key) {
137
+ // Re-saving without an image REPLACES the style — a stale reference
138
+ // image left behind would silently keep steering generations.
139
+ await bucket.delete(imageKey);
140
+ }
141
+ await bucket.put(jsonKey, JSON.stringify(record, null, 2), { httpMetadata: { contentType: 'application/json' } });
142
+ return record;
143
+ },
144
+ async getStyle(name) {
145
+ return readJson(await keyFor('styles', name, 'json'));
146
+ },
147
+ readStyleImage: (name) => readImage('styles', name),
148
+ listStyles: () => listRecords('styles'),
149
+ deleteStyle: (name) => deleteEntry('styles', name),
150
+ };
151
+ }
@@ -17,8 +17,11 @@ const DEFAULT_MAX_OBJECTS = 5_000;
17
17
  * `gen/` is where media is written now; `media/` is where it was written before
18
18
  * the rename that made URLs read `/media/gen/…` instead of `/media/media/…`.
19
19
  * Both are swept — retention that ignores the existing backlog is not retention.
20
+ * `up/` (signed uploads) is swept on the same schedule: a reference photo worth
21
+ * keeping gets copied into the character library (`lib/`), which is the ONE
22
+ * prefix deliberately absent here — saved characters and styles have no expiry.
20
23
  */
21
- const LEGACY_AND_CURRENT_PREFIXES = ['gen/', 'media/'];
24
+ const LEGACY_AND_CURRENT_PREFIXES = ['gen/', 'media/', 'up/'];
22
25
  /** R2 accepts bulk deletes in batches; 1000 is the documented ceiling. */
23
26
  const DELETE_BATCH = 1_000;
24
27
  /** Days → ms, defaulting to 7 for anything missing or nonsensical. */
@@ -59,6 +59,12 @@ function baseHeaders(key, object, size) {
59
59
  // The bytes are model output being handed to a browser — never let a
60
60
  // sniffed content type turn an image response into something executable.
61
61
  'x-content-type-options': 'nosniff',
62
+ // Defense in depth against stored XSS: this route serves user-influenced
63
+ // bytes from the SAME ORIGIN as the connector's OAuth pages, so if a
64
+ // scriptable document type (SVG, HTML) ever slipped past the raster-only
65
+ // upload gates, this neutralizes it — no script, no fetch, and a unique
66
+ // opaque origin via sandbox. Pixels don't need any of what this disables.
67
+ 'content-security-policy': "default-src 'none'; sandbox",
62
68
  // Range support is cheap on R2 and is what makes a resumable `curl -C -`
63
69
  // work against a large video.
64
70
  'accept-ranges': 'bytes',
package/dist/media-url.js CHANGED
@@ -19,14 +19,14 @@
19
19
  export const MEDIA_URL_TTL_MS = 48 * 60 * 60 * 1000;
20
20
  /** KV key holding the lazily-generated signing secret (zero-config path). */
21
21
  export const SIGNING_SECRET_KV_KEY = 'media:url-signing-secret';
22
- function base64UrlEncode(bytes) {
22
+ export function base64UrlEncode(bytes) {
23
23
  const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
24
24
  let binary = '';
25
25
  for (let i = 0; i < view.length; i++)
26
26
  binary += String.fromCharCode(view[i]);
27
27
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
28
28
  }
29
- function base64UrlDecode(text) {
29
+ export function base64UrlDecode(text) {
30
30
  const padded = text.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(text.length / 4) * 4, '=');
31
31
  const binary = atob(padded);
32
32
  const out = new Uint8Array(binary.length);
@@ -0,0 +1,122 @@
1
+ import { loadSigningKey } from './media-url.js';
2
+ import { verifyUploadSignature, UPLOAD_MAX_BYTES, UPLOAD_KEY_PREFIX, acceptableUploadType } from './upload-url.js';
3
+ import { wholeMb } from './bytes.js';
4
+ function json(body, status) {
5
+ return new Response(JSON.stringify(body, null, 2), {
6
+ status,
7
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
8
+ });
9
+ }
10
+ /** Every error names the fix — the caller here is a shell, not a model. */
11
+ function fail(status, error, hint) {
12
+ return json({ error, hint }, status);
13
+ }
14
+ /** A key this endpoint is willing to even verify a signature for: the upload
15
+ * prefix, safe segment charset, no dot-segments, no empty segments. */
16
+ export function wellFormedUploadKey(key) {
17
+ if (!key.startsWith(`${UPLOAD_KEY_PREFIX}/`))
18
+ return false;
19
+ const segments = key.split('/');
20
+ if (segments.length < 2)
21
+ return false;
22
+ return segments.every((s) => s.length > 0 && s !== '.' && s !== '..' && /^[A-Za-z0-9._-]+$/.test(s));
23
+ }
24
+ /**
25
+ * Build the `/put` handler. `basePath` is stripped from the pathname to get the
26
+ * R2 key, mirroring the `/media` handler so the two never drift.
27
+ */
28
+ export function createSignedPutHandler(env, basePath = '/put/') {
29
+ const maxBytes = env.maxBytes ?? UPLOAD_MAX_BYTES;
30
+ return async function handleSignedPut(request) {
31
+ if (request.method === 'OPTIONS') {
32
+ return new Response(null, { status: 204, headers: { allow: 'PUT, OPTIONS' } });
33
+ }
34
+ if (request.method !== 'PUT') {
35
+ return fail(405, `Method ${request.method} not allowed; signed upload URLs are PUT-only.`, 'Send the file bytes with: curl -sS -X PUT -H "Content-Type: <the signed type>" --data-binary @photo.jpg "<upload_url>"');
36
+ }
37
+ const url = new URL(request.url);
38
+ if (!url.pathname.startsWith(basePath))
39
+ return fail(404, 'Not found.', 'Use the exact upload_url returned by gemini_get_upload_url.');
40
+ const key = url.pathname
41
+ .slice(basePath.length)
42
+ .split('/')
43
+ .map((segment) => {
44
+ try {
45
+ return decodeURIComponent(segment);
46
+ }
47
+ catch {
48
+ return segment;
49
+ }
50
+ })
51
+ .join('/');
52
+ // Charset-checked BEFORE signature verification: the cross-protocol safety
53
+ // argument in upload-url.ts rests on keys never containing control chars.
54
+ if (!wellFormedUploadKey(key)) {
55
+ return fail(404, 'Not a valid upload key.', 'Use the exact upload_url returned by gemini_get_upload_url — the key path cannot be altered.');
56
+ }
57
+ const ct = url.searchParams.get('ct');
58
+ if (ct && !acceptableUploadType(ct)) {
59
+ return fail(415, `Unsupported content type "${ct}" — signed uploads accept raster image types only ` +
60
+ '(jpeg/png/webp/gif/avif/heic/heif/bmp/tiff; not SVG, which can carry scripts).', 'Mint the URL with a raster image content_type (e.g. image/jpeg).');
61
+ }
62
+ const now = env.now?.() ?? Date.now();
63
+ // Read-only key load: verification must never CREATE a signing secret (see
64
+ // loadSigningKey — a stale KV read would clobber the secret every
65
+ // outstanding media link was signed with).
66
+ const signingKey = await loadSigningKey(env.secret, env.store);
67
+ const check = signingKey
68
+ ? await verifyUploadSignature(signingKey, key, ct, url.searchParams.get('exp'), url.searchParams.get('sig'), now)
69
+ : { ok: false, reason: 'invalid' };
70
+ if (!check.ok) {
71
+ if (check.reason === 'expired') {
72
+ return fail(410, 'This upload URL has expired (they are valid for ~10 minutes).', 'Mint a fresh one with gemini_get_upload_url and retry.');
73
+ }
74
+ return fail(403, 'Invalid or missing upload signature.', 'Use the upload_url exactly as returned by gemini_get_upload_url, including the ct, exp and sig query parameters.');
75
+ }
76
+ const sentType = (request.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
77
+ if (sentType !== ct) {
78
+ return fail(415, `Content-Type "${sentType || '(none)'}" does not match the type this URL was minted for ("${ct}").`, `Send -H "Content-Type: ${ct}", or mint a new URL with the right content_type.`);
79
+ }
80
+ const declared = Number(request.headers.get('content-length'));
81
+ if (!request.headers.get('content-length') || !Number.isFinite(declared) || declared <= 0) {
82
+ return fail(411, 'Content-Length is required (and must be > 0).', 'curl sets it automatically with --data-binary @file.');
83
+ }
84
+ if (declared > maxBytes) {
85
+ return fail(413, `Upload is ${wholeMb(declared)}MB; the cap is ${wholeMb(maxBytes)}MB.`, 'Downscale the image before uploading — a reference photo does not need to be full-resolution.');
86
+ }
87
+ if (!request.body) {
88
+ return fail(400, 'Request had no body.', 'Send the file bytes as the request body, e.g. --data-binary @photo.jpg.');
89
+ }
90
+ // Enforce the cap on bytes actually READ, not the declared length — a
91
+ // hostile client can under-declare and stream more.
92
+ const chunks = [];
93
+ let total = 0;
94
+ const reader = request.body.getReader();
95
+ for (;;) {
96
+ const { done, value } = await reader.read();
97
+ if (done)
98
+ break;
99
+ total += value.byteLength;
100
+ if (total > maxBytes) {
101
+ await reader.cancel().catch(() => { });
102
+ return fail(413, `Upload exceeded the ${wholeMb(maxBytes)}MB cap while being read.`, 'Downscale the image before uploading.');
103
+ }
104
+ chunks.push(value);
105
+ }
106
+ const body = new Uint8Array(total);
107
+ let offset = 0;
108
+ for (const chunk of chunks) {
109
+ body.set(chunk, offset);
110
+ offset += chunk.byteLength;
111
+ }
112
+ await env.bucket.put(key, body, { httpMetadata: { contentType: ct ?? 'application/octet-stream' } });
113
+ return json({
114
+ r2_key: key,
115
+ size_bytes: total,
116
+ content_type: ct,
117
+ hint: `Uploaded. Reference it by r2_key: pass it in images_r2_keys on a generation tool (master_images_r2_keys on gemini_image_set), ` +
118
+ `save it permanently with gemini_save_character, or convert it to a ~48h Gemini Files API reference with gemini_upload_file({ r2_key }). ` +
119
+ `Uploads follow the connector's media retention schedule (MEDIA_TTL_DAYS, default 7 days); saved characters are kept indefinitely.`,
120
+ }, 201);
121
+ };
122
+ }
@@ -45,7 +45,7 @@ export async function tenantIdFor(secret) {
45
45
  // prefix would make every URL read `/media/media/…`.
46
46
  const KEY_PREFIX_DEFAULT = 'gen';
47
47
  /** Object-key-safe name: no slashes, no dot-segments, never empty. */
48
- function safeKeySegment(base) {
48
+ export function safeKeySegment(base) {
49
49
  const cleaned = base
50
50
  .replace(/[^A-Za-z0-9._-]+/g, '-')
51
51
  .replace(/\.{2,}/g, '-')
@@ -67,18 +67,27 @@ export function createR2Sink(bucket, opts) {
67
67
  const publicBase = opts.publicBaseUrl?.replace(/\/+$/, '');
68
68
  const signedBase = opts.signedBaseUrl?.replace(/\/+$/, '');
69
69
  const ttlMs = opts.urlTtlMs ?? 0;
70
- /** `<prefix>/` or `<prefix>/<tenant>/` — every key this sink may touch. */
71
- let ownPrefixCache;
72
- const ownPrefix = () => (ownPrefixCache ??= (async () => {
73
- const tenant = typeof opts.tenant === 'function' ? await opts.tenant() : opts.tenant;
70
+ const readPrefixes = [prefix, ...(opts.readPrefixes ?? [])];
71
+ let tenantCache;
72
+ const tenantId = () => (tenantCache ??= Promise.resolve(typeof opts.tenant === 'function' ? opts.tenant() : opts.tenant));
73
+ /** `<prefix>/` or `<prefix>/<tenant>/` — where every WRITE goes. */
74
+ const ownPrefix = async () => {
75
+ const tenant = await tenantId();
74
76
  return tenant ? `${prefix}/${tenant}/` : `${prefix}/`;
75
- })());
77
+ };
76
78
  /**
77
- * Ownership gate for caller-supplied keys. A foreign key is refused without
78
- * touching storage, so the refusal is indistinguishable from a swept object.
79
- * With no tenant configured there is nothing to enforce (single-user shapes).
79
+ * Ownership gate for caller-supplied keys: this session's own namespace
80
+ * under any readable prefix (`gen/<tenant>/…`, plus e.g. `up/`/`lib/` on the
81
+ * Worker). A foreign key is refused without touching storage, so the refusal
82
+ * is indistinguishable from a swept object. With no tenant configured there
83
+ * is nothing to enforce (single-user shapes).
80
84
  */
81
- const owned = async (key) => !opts.tenant || key.startsWith(await ownPrefix());
85
+ const owned = async (key) => {
86
+ if (!opts.tenant)
87
+ return true;
88
+ const tenant = await tenantId();
89
+ return readPrefixes.some((p) => key.startsWith(`${p}/${tenant}/`));
90
+ };
82
91
  return {
83
92
  kind: 'r2',
84
93
  persistsFiles: false,
@@ -102,14 +111,16 @@ export function createR2Sink(bucket, opts) {
102
111
  return undefined;
103
112
  return describe(key, now().getTime() + ttlMs);
104
113
  },
105
- async persist(items, _opts) {
114
+ async persist(items, persistOpts) {
106
115
  // `mediaExt` is pure string work but lives in images.ts next to node:fs
107
116
  // imports; the dynamic import keeps that module off a Worker's eager
108
117
  // module graph.
109
118
  const { mediaExt } = await import('../images.js');
110
119
  const keyBase = await ownPrefix();
111
120
  const day = now().toISOString().slice(0, 10);
112
- const expiresAtMs = now().getTime() + ttlMs;
121
+ // Per-persist TTL override, clamped so a link never outlives its object.
122
+ const effectiveTtlMs = Math.min(persistOpts.urlTtlMs ?? ttlMs, opts.maxUrlTtlMs ?? Infinity);
123
+ const expiresAtMs = now().getTime() + effectiveTtlMs;
113
124
  const refs = [];
114
125
  for (const it of items) {
115
126
  const key = `${keyBase}${day}/${randomId()}-${safeKeySegment(it.base)}.${mediaExt(it.mimeType)}`;
@@ -3,7 +3,7 @@ import { readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveModel } from '../models.js';
4
4
  import { slugify, baseName } from '../images.js';
5
5
  import { resolveImageInputs, requireImageInput } from '../inputs.js';
6
- import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
6
+ import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesR2KeysSchema, charactersSchema, styleSchema, resolveCharacterRefs, composePrompt } from './shared.js';
7
7
  import { fingerprintRequest } from '../jobs.js';
8
8
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
9
9
  const GENERATE_ENDPOINT = '/v1beta/models/{model}:generateContent';
@@ -24,7 +24,10 @@ export function registerGenerateTools(server, client) {
24
24
  images: z.array(z.string().min(1)).optional().describe('Paths to reference input images (image-conditioned generation)'),
25
25
  images_url: imagesUrlSchema(),
26
26
  images_file_uris: imagesFileUrisSchema(),
27
+ images_r2_keys: imagesR2KeysSchema(),
27
28
  images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation'),
29
+ characters: charactersSchema,
30
+ style: styleSchema,
28
31
  video_url: z.string().url().optional().describe('Public YouTube URL (or a previously uploaded Files API uri) as a video reference (video→image; use a Flash model e.g. gemini-3.1-flash-image)'),
29
32
  video_path: videoPathSchema,
30
33
  confirm: schemaConfirm,
@@ -49,15 +52,22 @@ export function registerGenerateTools(server, client) {
49
52
  model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
50
53
  thinking_level: args.thinking_level, google_search: args.google_search, count,
51
54
  images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
52
- images_url: args.images_url, images_file_uris: args.images_file_uris,
55
+ images_url: args.images_url, images_file_uris: args.images_file_uris, images_r2_keys: args.images_r2_keys,
56
+ characters: args.characters, style: args.style,
53
57
  video_url: args.video_url, video_path: args.video_path,
54
58
  });
55
- return client.session.jobs.dispatch({ toolName: 'gemini_image_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
59
+ return client.session.jobs.dispatch({ toolName: 'gemini_image_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
56
60
  const seed = pickSeed(args.seed);
57
61
  const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
62
+ // Saved characters/style resolve first (and fail fast on an unknown
63
+ // name — before anything billable), so their reference images lead the
64
+ // input order the prompt preamble describes.
65
+ const charRefs = await resolveCharacterRefs(client, args.characters, args.style);
66
+ const prompt = composePrompt(args.prompt, charRefs);
58
67
  // Resolved ONCE, before the count loop: a URL is fetched (and a large
59
68
  // one uploaded) a single time no matter how many images are generated.
60
- const { inputs: refInputs, report } = await withProgressHeartbeat(extra, 'Resolving reference images', () => resolveImageInputs(args, client));
69
+ const { inputs: directInputs, report } = await withProgressHeartbeat(extra, 'Resolving reference images', () => resolveImageInputs(args, client));
70
+ const refInputs = [...charRefs.inputs, ...directInputs];
61
71
  // Upload ONCE (before the count loop) — every generate call references
62
72
  // the same files/… uri.
63
73
  const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args, client));
@@ -79,7 +89,7 @@ export function registerGenerateTools(server, client) {
79
89
  // Distinct seed per image (seed+0 for the single-image case == echoed seed)
80
90
  // so count>1 yields N *different* images, not N duplicates.
81
91
  const result = await client.generate({
82
- prompt: args.prompt,
92
+ prompt,
83
93
  images: refInputs.length > 0 ? refInputs : undefined,
84
94
  model: args.model,
85
95
  aspectRatio: args.aspect_ratio,
@@ -113,6 +123,8 @@ export function registerGenerateTools(server, client) {
113
123
  meta.grounding = capturedGrounding;
114
124
  if (report)
115
125
  meta.image_inputs = report;
126
+ if (charRefs.meta)
127
+ Object.assign(meta, charRefs.meta);
116
128
  if (failed.length) {
117
129
  meta.failed_images = failed;
118
130
  meta.partial =
@@ -139,14 +151,20 @@ export function registerGenerateTools(server, client) {
139
151
  images: z.array(z.string().min(1)).optional().describe('Paths to input image file(s) (1 = edit, 2+ = compose)'),
140
152
  images_url: imagesUrlSchema('Input images'),
141
153
  images_file_uris: imagesFileUrisSchema('Input images'),
154
+ images_r2_keys: imagesR2KeysSchema('Input images'),
142
155
  images_base64: z.array(z.string().min(1)).optional().describe('Input images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation'),
156
+ characters: charactersSchema,
157
+ style: styleSchema,
143
158
  filename: z.string().optional().describe('Base filename for the output image (extension stripped; default: slugified prompt)'),
144
159
  confirm: schemaConfirm,
145
160
  ...sharedImageSchema,
146
161
  },
147
162
  }, async (args, extra) => {
148
163
  assertLocalInputsAvailable(client.mediaSink, args);
149
- requireImageInput(args);
164
+ // Saved characters count as input images for the required-input check —
165
+ // the library attaches their references inside the handler.
166
+ if (!args.characters?.length)
167
+ requireImageInput(args);
150
168
  // Confirm-gate local file inputs (see gemini_image_generate). base64 /
151
169
  // clipboard inputs are not local-path reads and pass through ungated.
152
170
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
@@ -157,14 +175,18 @@ export function registerGenerateTools(server, client) {
157
175
  model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
158
176
  thinking_level: args.thinking_level, google_search: args.google_search,
159
177
  images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
160
- images_url: args.images_url, images_file_uris: args.images_file_uris,
178
+ images_url: args.images_url, images_file_uris: args.images_file_uris, images_r2_keys: args.images_r2_keys,
179
+ characters: args.characters, style: args.style,
161
180
  });
162
- return client.session.jobs.dispatch({ toolName: 'gemini_image_edit', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
181
+ return client.session.jobs.dispatch({ toolName: 'gemini_image_edit', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
163
182
  const seed = pickSeed(args.seed);
164
183
  const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
165
- const { inputs, report } = await withProgressHeartbeat(extra, 'Resolving input images', () => resolveImageInputs(args, client));
184
+ const charRefs = await resolveCharacterRefs(client, args.characters, args.style);
185
+ const prompt = composePrompt(args.prompt, charRefs);
186
+ const { inputs: directInputs, report } = await withProgressHeartbeat(extra, 'Resolving input images', () => resolveImageInputs(args, client));
187
+ const inputs = [...charRefs.inputs, ...directInputs];
166
188
  const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client.generate({
167
- prompt: args.prompt,
189
+ prompt,
168
190
  images: inputs,
169
191
  model: args.model,
170
192
  aspectRatio: args.aspect_ratio,
@@ -182,6 +204,8 @@ export function registerGenerateTools(server, client) {
182
204
  meta.grounding = result.grounding;
183
205
  if (report)
184
206
  meta.image_inputs = report;
207
+ if (charRefs.meta)
208
+ Object.assign(meta, charRefs.meta);
185
209
  meta.hint = REFINE_HINT;
186
210
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: client.mediaSink?.persistsFiles });
187
211
  if (risk)