@chrischall/gemini-mcp 1.3.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.
@@ -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
+ }
@@ -36,11 +36,16 @@ export function createDiskSink() {
36
36
  note: () => undefined,
37
37
  };
38
38
  }
39
+ /** Stable, non-reversible short tenant id from a per-session secret. */
40
+ export async function tenantIdFor(secret) {
41
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));
42
+ return [...new Uint8Array(digest).slice(0, 6)].map((b) => b.toString(16).padStart(2, '0')).join('');
43
+ }
39
44
  // `gen`, not `media`: the objects are served at `/media/<key>`, and a `media`
40
45
  // prefix would make every URL read `/media/media/…`.
41
46
  const KEY_PREFIX_DEFAULT = 'gen';
42
47
  /** Object-key-safe name: no slashes, no dot-segments, never empty. */
43
- function safeKeySegment(base) {
48
+ export function safeKeySegment(base) {
44
49
  const cleaned = base
45
50
  .replace(/[^A-Za-z0-9._-]+/g, '-')
46
51
  .replace(/\.{2,}/g, '-')
@@ -62,19 +67,63 @@ export function createR2Sink(bucket, opts) {
62
67
  const publicBase = opts.publicBaseUrl?.replace(/\/+$/, '');
63
68
  const signedBase = opts.signedBaseUrl?.replace(/\/+$/, '');
64
69
  const ttlMs = opts.urlTtlMs ?? 0;
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();
76
+ return tenant ? `${prefix}/${tenant}/` : `${prefix}/`;
77
+ };
78
+ /**
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).
84
+ */
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
+ };
65
91
  return {
66
92
  kind: 'r2',
67
93
  persistsFiles: false,
68
- async persist(items, _opts) {
94
+ async read(key) {
95
+ if (!bucket.get || !(await owned(key)))
96
+ return undefined;
97
+ const object = await bucket.get(key);
98
+ if (!object)
99
+ return undefined;
100
+ return {
101
+ bytes: new Uint8Array(await object.arrayBuffer()),
102
+ mimeType: object.httpMetadata?.contentType ?? 'application/octet-stream',
103
+ };
104
+ },
105
+ async resign(key) {
106
+ if (!(await owned(key)))
107
+ return undefined;
108
+ // Only for objects that still exist — re-signing a swept key would hand
109
+ // back a link that 404s, which is no better than the expired one.
110
+ if (bucket.get && !(await bucket.get(key)))
111
+ return undefined;
112
+ return describe(key, now().getTime() + ttlMs);
113
+ },
114
+ async persist(items, persistOpts) {
69
115
  // `mediaExt` is pure string work but lives in images.ts next to node:fs
70
116
  // imports; the dynamic import keeps that module off a Worker's eager
71
117
  // module graph.
72
118
  const { mediaExt } = await import('../images.js');
119
+ const keyBase = await ownPrefix();
73
120
  const day = now().toISOString().slice(0, 10);
74
- 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;
75
124
  const refs = [];
76
125
  for (const it of items) {
77
- const key = `${prefix}/${day}/${randomId()}-${safeKeySegment(it.base)}.${mediaExt(it.mimeType)}`;
126
+ const key = `${keyBase}${day}/${randomId()}-${safeKeySegment(it.base)}.${mediaExt(it.mimeType)}`;
78
127
  await bucket.put(key, base64ToBytes(it.base64), { httpMetadata: { contentType: it.mimeType } });
79
128
  refs.push(await describe(key, expiresAtMs));
80
129
  }
@@ -3,6 +3,7 @@ import { McpToolError, textResult } from '@chrischall/mcp-utils';
3
3
  import { withProgressHeartbeat } from './shared.js';
4
4
  import { base64ToBytes, wholeMb } from '../bytes.js';
5
5
  import { fileNameFromUrl } from '../fetch-image.js';
6
+ import { downloadFilename } from '../media-endpoint.js';
6
7
  import { previewUnlessConfirmed, previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
7
8
  /**
8
9
  * First-class Gemini Files API access.
@@ -70,7 +71,9 @@ export function registerFileTools(server, client) {
70
71
  'Use this instead of pasting base64 into a tool call: the reference is a short string, so no image bytes ever ' +
71
72
  'enter the conversation, and it can be reused across many generations until it expires (~48h). ' +
72
73
  'Provide exactly one of `url` (the server downloads it), `data_base64`' +
73
- (onDisk ? ', or `path` (a local file).' : '.') +
74
+ (onDisk
75
+ ? ', or `path` (a local file).'
76
+ : ', or `r2_key` (re-upload media this connector generated, from media[].r2_key in an earlier result).') +
74
77
  (onDisk
75
78
  ? ''
76
79
  : ' This connector also accepts raw bytes over HTTP: POST them to /upload with the same Authorization header ' +
@@ -89,6 +92,16 @@ export function registerFileTools(server, client) {
89
92
  .min(1)
90
93
  .optional()
91
94
  .describe('Raw base64 or a data: URI. Last resort — this is the one form that costs model context (~14k tokens for a modest JPEG).'),
95
+ r2_key: z
96
+ .string()
97
+ .min(1)
98
+ .optional()
99
+ .describe(onDisk
100
+ ? 'Unavailable on this server (media is written to local disk) — pass the file path via `path` instead.'
101
+ : 'An `r2_key` from a previous generation result (media[].r2_key) — re-uploads media THIS connector generated, ' +
102
+ 'so a generated image can become a reference image in one cheap call. The server reads its own bucket directly: ' +
103
+ 'no HTTP request, no signed URL, and no expiry to worry about. Do NOT pass a /media URL as `url` for this — ' +
104
+ 'that would be the connector fetching itself and needs a signature.'),
92
105
  path: z
93
106
  .string()
94
107
  .min(1)
@@ -105,10 +118,10 @@ export function registerFileTools(server, client) {
105
118
  confirm: schemaConfirm,
106
119
  },
107
120
  }, async (args, extra) => {
108
- const sources = [args.url && 'url', args.data_base64 && 'data_base64', args.path && 'path'].filter(Boolean);
121
+ const sources = [args.url && 'url', args.data_base64 && 'data_base64', args.r2_key && 'r2_key', args.path && 'path'].filter(Boolean);
109
122
  if (sources.length !== 1) {
110
123
  throw new McpToolError(sources.length === 0
111
- ? 'Provide exactly one of `url`, `data_base64`' + (onDisk ? ' or `path`.' : '.')
124
+ ? 'Provide exactly one of `url`, `data_base64`' + (onDisk ? ' or `path`.' : ' or `r2_key`.')
112
125
  : `Provide exactly one source, not ${sources.length} (${sources.join(', ')}).`);
113
126
  }
114
127
  if (args.path) {
@@ -131,6 +144,17 @@ export function registerFileTools(server, client) {
131
144
  // this tool takes video and audio too, so 'image' would be a lie.
132
145
  args.display_name ?? fileNameFromUrl(args.url, 'upload'));
133
146
  }
147
+ if (args.r2_key) {
148
+ if (onDisk) {
149
+ throw new McpToolError('`r2_key` is only available on the hosted connector — this server writes media to local disk, ' +
150
+ 'so pass the generated file via `path` instead.');
151
+ }
152
+ // Reading our own bucket rather than fetching our own /media URL. A
153
+ // self-fetch would need a signature the caller cannot mint, which is
154
+ // exactly the 403 loop this parameter exists to remove.
155
+ const stored = await client.readStoredMedia(args.r2_key);
156
+ return client.uploadBytes(stored.bytes, args.mime_type ?? stored.mimeType, args.display_name ?? downloadFilename(args.r2_key));
157
+ }
134
158
  if (args.data_base64) {
135
159
  const { decodeImageInput } = await import('../images.js');
136
160
  const decoded = decodeImageInput(args.data_base64);
@@ -171,6 +195,40 @@ export function registerFileTools(server, client) {
171
195
  : 'No files uploaded (or all have expired — retention is ~48h). Upload one with gemini_upload_file.',
172
196
  });
173
197
  });
198
+ // Hosted connector only. On a disk-backed server the result carries a path
199
+ // that never expires, so a "refresh my link" tool would only ever error —
200
+ // don't advertise it there (mirrors the Worker not registering video).
201
+ if (!onDisk) {
202
+ server.registerTool('gemini_sign_media', {
203
+ description: 'Mint a FRESH signed URL for media this connector generated, from its `r2_key`. ' +
204
+ 'Signed media links expire well before the object behind them is cleaned up, so an expired link is not a dead ' +
205
+ 'end — re-sign it instead of re-generating (and re-paying for) the image. Keywords: expired link, refresh url, ' +
206
+ 're-sign, media url, download again.',
207
+ annotations: { readOnlyHint: true, openWorldHint: false },
208
+ inputSchema: {
209
+ r2_key: z.string().min(1).describe('The `media[].r2_key` from an earlier generation result'),
210
+ },
211
+ }, async (args) => {
212
+ const sink = client.mediaSink;
213
+ if (!sink?.resign) {
214
+ throw new McpToolError('This server does not serve media URLs — generated files are written to disk and the path in the result is already usable.', {
215
+ hint: 'gemini_sign_media applies to the hosted connector only.',
216
+ });
217
+ }
218
+ const fresh = await sink.resign(args.r2_key.trim());
219
+ if (!fresh) {
220
+ // Remediation goes in the MESSAGE: MCP serialization drops hints.
221
+ throw new McpToolError(`No stored media for r2_key "${args.r2_key}" — it has probably passed its retention window. Re-run the generation to get a new one.`, { hint: 'Objects are swept on a retention schedule (MEDIA_TTL_DAYS, default 7 days).' });
222
+ }
223
+ return textResult({
224
+ url: fresh.ref,
225
+ r2_key: fresh.key,
226
+ ...(fresh.expiresAt ? { expires_at: fresh.expiresAt } : {}),
227
+ curl_hint: `curl -sS -o ${downloadFilename(fresh.key ?? 'media')} "${fresh.ref}"`,
228
+ hint: 'In a Claude chat client, download this URL into your sandbox and present it as an output file — inline image blocks and markdown embeds do not render for the end user.',
229
+ });
230
+ });
231
+ }
174
232
  server.registerTool('gemini_delete_file', {
175
233
  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 ' +
176
234
  'will then fail with a generic 404, so delete only references you are finished with.',
@@ -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));
@@ -66,31 +76,46 @@ export function registerGenerateTools(server, client) {
66
76
  // For count>1, surface the FIRST call's grounding (each call grounds
67
77
  // independently; one representative set of sources beats concatenating N).
68
78
  let capturedGrounding;
79
+ // count>1 is N billed generations in one tools/call, and any one can
80
+ // fail (a safety filter, a transient 5xx). Failures are collected per
81
+ // image so one bad draw cannot lose the ones that succeeded — same
82
+ // contract as gemini_image_set's failed_scenes. Zero successes still
83
+ // throws: an empty success is worse than an error.
84
+ const failed = [];
85
+ let firstError;
69
86
  await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : 'image'} (${model})`, async () => {
70
87
  for (let i = 0; i < count; i++) {
71
- // Distinct seed per image (seed+0 for the single-image case == echoed seed)
72
- // so count>1 yields N *different* images, not N duplicates.
73
- const result = await client.generate({
74
- prompt: args.prompt,
75
- images: refInputs.length > 0 ? refInputs : undefined,
76
- model: args.model,
77
- aspectRatio: args.aspect_ratio,
78
- imageSize: args.image_size,
79
- seed: seed + i,
80
- thinkingLevel: args.thinking_level,
81
- googleSearch: args.google_search,
82
- videoUrl: video.videoUrl,
83
- videoMimeType: video.videoMimeType,
84
- timeoutMs: args.timeout_ms,
85
- });
86
- const { images: [img], text } = result;
87
- if (text && !capturedText)
88
- capturedText = text;
89
- if (result.grounding && !capturedGrounding)
90
- capturedGrounding = result.grounding;
91
- named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, '0')}` });
88
+ try {
89
+ // Distinct seed per image (seed+0 for the single-image case == echoed seed)
90
+ // so count>1 yields N *different* images, not N duplicates.
91
+ const result = await client.generate({
92
+ prompt,
93
+ images: refInputs.length > 0 ? refInputs : undefined,
94
+ model: args.model,
95
+ aspectRatio: args.aspect_ratio,
96
+ imageSize: args.image_size,
97
+ seed: seed + i,
98
+ thinkingLevel: args.thinking_level,
99
+ googleSearch: args.google_search,
100
+ videoUrl: video.videoUrl,
101
+ videoMimeType: video.videoMimeType,
102
+ timeoutMs: args.timeout_ms,
103
+ });
104
+ const { images: [img], text } = result;
105
+ if (text && !capturedText)
106
+ capturedText = text;
107
+ if (result.grounding && !capturedGrounding)
108
+ capturedGrounding = result.grounding;
109
+ named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, '0')}` });
110
+ }
111
+ catch (err) {
112
+ firstError ??= err;
113
+ failed.push({ image: i + 1, error: err instanceof Error ? err.message : String(err) });
114
+ }
92
115
  }
93
116
  });
117
+ if (named.length === 0)
118
+ throw firstError;
94
119
  const meta = buildMeta(model, seed, args);
95
120
  if (capturedText)
96
121
  meta.text = capturedText;
@@ -98,6 +123,14 @@ export function registerGenerateTools(server, client) {
98
123
  meta.grounding = capturedGrounding;
99
124
  if (report)
100
125
  meta.image_inputs = report;
126
+ if (charRefs.meta)
127
+ Object.assign(meta, charRefs.meta);
128
+ if (failed.length) {
129
+ meta.failed_images = failed;
130
+ meta.partial =
131
+ `${failed.length} of ${count} image(s) failed; the ${named.length} that succeeded are returned above. ` +
132
+ 'Re-run with count set to the shortfall rather than the whole batch — each failure message is in failed_images.';
133
+ }
101
134
  if (video.videoFileMeta)
102
135
  meta.video_file = video.videoFileMeta;
103
136
  meta.hint = REFINE_HINT;
@@ -118,14 +151,20 @@ export function registerGenerateTools(server, client) {
118
151
  images: z.array(z.string().min(1)).optional().describe('Paths to input image file(s) (1 = edit, 2+ = compose)'),
119
152
  images_url: imagesUrlSchema('Input images'),
120
153
  images_file_uris: imagesFileUrisSchema('Input images'),
154
+ images_r2_keys: imagesR2KeysSchema('Input images'),
121
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,
122
158
  filename: z.string().optional().describe('Base filename for the output image (extension stripped; default: slugified prompt)'),
123
159
  confirm: schemaConfirm,
124
160
  ...sharedImageSchema,
125
161
  },
126
162
  }, async (args, extra) => {
127
163
  assertLocalInputsAvailable(client.mediaSink, args);
128
- 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);
129
168
  // Confirm-gate local file inputs (see gemini_image_generate). base64 /
130
169
  // clipboard inputs are not local-path reads and pass through ungated.
131
170
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
@@ -136,14 +175,18 @@ export function registerGenerateTools(server, client) {
136
175
  model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
137
176
  thinking_level: args.thinking_level, google_search: args.google_search,
138
177
  images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
139
- 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,
140
180
  });
141
- 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 () => {
142
182
  const seed = pickSeed(args.seed);
143
183
  const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
144
- 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];
145
188
  const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client.generate({
146
- prompt: args.prompt,
189
+ prompt,
147
190
  images: inputs,
148
191
  model: args.model,
149
192
  aspectRatio: args.aspect_ratio,
@@ -161,6 +204,8 @@ export function registerGenerateTools(server, client) {
161
204
  meta.grounding = result.grounding;
162
205
  if (report)
163
206
  meta.image_inputs = report;
207
+ if (charRefs.meta)
208
+ Object.assign(meta, charRefs.meta);
164
209
  meta.hint = REFINE_HINT;
165
210
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: client.mediaSink?.persistsFiles });
166
211
  if (risk)
@@ -5,7 +5,7 @@ import { ChainedRequest404Error } from '../client.js';
5
5
  import { slugify, baseName, resolveImagePath, writeSidecar, resolveOutputDir, readImageAsInline } from '../images.js';
6
6
  import { resolveImageInputs } from '../inputs.js';
7
7
  import { findInteractionImages, latestInteractionId } from '../sidecar.js';
8
- import { emit, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
8
+ import { emit, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
9
9
  import { fingerprintRequest } from '../jobs.js';
10
10
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
11
11
  // The last-interaction id and the written-outputs set used to live here, at
@@ -126,6 +126,7 @@ export function registerInteractTools(server, client) {
126
126
  timeout_ms: timeoutMsSchema,
127
127
  idempotency_key: idempotencyKeySchema,
128
128
  async: asyncSchema,
129
+ max_wait_ms: maxWaitMsSchema,
129
130
  from_clipboard: z
130
131
  .boolean()
131
132
  .optional()
@@ -180,7 +181,7 @@ export function registerInteractTools(server, client) {
180
181
  images_url: args.images_url, images_file_uris: args.images_file_uris,
181
182
  video_url: args.video_url, video_path: args.video_path,
182
183
  });
183
- return client.session.jobs.dispatch({ toolName: 'gemini_interact', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
184
+ return client.session.jobs.dispatch({ toolName: 'gemini_interact', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
184
185
  const { inputs, report } = await resolveImageInputs({ ...args, images }, client);
185
186
  const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args, client));
186
187
  const callOpts = {