@chrischall/gemini-mcp 1.4.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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)
@@ -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 = {
@@ -0,0 +1,186 @@
1
+ import { z } from 'zod';
2
+ import { McpToolError, textResult } from '@chrischall/mcp-utils';
3
+ import { LIBRARY_NAME_PATTERN } from '../library.js';
4
+ import { acceptableUploadType } from '../upload-url.js';
5
+ import { withProgressHeartbeat } from './shared.js';
6
+ import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
7
+ /**
8
+ * Tools over the per-account reference library (src/library.ts): named
9
+ * characters and styles that persist across sessions with NO expiry.
10
+ *
11
+ * Hosted connector only — the library lives in the connector's R2 bucket, so
12
+ * on stdio (no bucket) none of these register, mirroring how
13
+ * `gemini_sign_media` is absent on disk sinks.
14
+ */
15
+ const NAME_FIELD = z
16
+ .string()
17
+ .regex(LIBRARY_NAME_PATTERN, 'must be 1-40 lowercase letters, digits, "-" or "_"')
18
+ .describe('Library name (slug): 1-40 lowercase letters, digits, "-" or "_", e.g. "finn" or "bold-cartoon-sports"');
19
+ const USE_CHARACTERS_HINT = 'Pass characters: ["<name>", …] on gemini_image_generate / gemini_image_edit / gemini_image_set and the saved ' +
20
+ 'reference image + description are attached to the request automatically — nothing to re-upload and no bytes in the conversation.';
21
+ const USE_STYLE_HINT = 'Pass style: "<name>" on gemini_image_generate / gemini_image_edit / gemini_image_set and the saved prompt fragment ' +
22
+ '(and reference image, if any) are applied automatically.';
23
+ /**
24
+ * Resolve the one image source a save call may carry. `image_r2_key` reads the
25
+ * connector's own store (a signed upload or an earlier generation — tenant
26
+ * gated); `image_url` is fetched by the server with the same SSRF guards as
27
+ * `images_url`.
28
+ */
29
+ async function resolveLibraryImage(client, args, required) {
30
+ if (args.image_r2_key && args.image_url) {
31
+ throw new McpToolError('Provide `image_r2_key` OR `image_url`, not both.');
32
+ }
33
+ // Raster only, on BOTH sources: library images can be re-signed and served
34
+ // from /media (the connector's own origin), so a scriptable SVG saved here
35
+ // would be stored XSS — the same gate the signed-upload path enforces.
36
+ const rasterOnly = (image) => {
37
+ if (!acceptableUploadType(image.mimeType)) {
38
+ throw new McpToolError(`The reference image is "${image.mimeType}", but the library accepts raster images only ` +
39
+ '(jpeg/png/webp/gif/avif/heic/heif/bmp/tiff — not SVG, which can carry scripts). Convert it to PNG or JPEG first.');
40
+ }
41
+ return image;
42
+ };
43
+ if (args.image_r2_key) {
44
+ return rasterOnly(await client.readStoredMedia(args.image_r2_key));
45
+ }
46
+ if (args.image_url) {
47
+ const fetched = await client.fetchRemoteImage(args.image_url);
48
+ return rasterOnly({ bytes: fetched.bytes, mimeType: fetched.mimeType });
49
+ }
50
+ if (required) {
51
+ throw new McpToolError('Provide the reference image via `image_r2_key` (from gemini_get_upload_url + PUT, or a generation result\'s media[].r2_key) ' +
52
+ 'or `image_url` (a public https URL the server fetches).');
53
+ }
54
+ return undefined;
55
+ }
56
+ export function registerLibraryTools(server, client) {
57
+ const library = client.library;
58
+ if (!library)
59
+ return;
60
+ server.registerTool('gemini_save_character', {
61
+ description: 'Save a named character to this account\'s persistent reference library: one reference image plus a short ' +
62
+ 'description. Saved characters have NO expiry — unlike uploads (~retention window) and Files API references (~48h) ' +
63
+ '— so a name saved once keeps working in every later session. Re-saving a name replaces it. ' +
64
+ USE_CHARACTERS_HINT,
65
+ annotations: { readOnlyHint: false, openWorldHint: false },
66
+ inputSchema: {
67
+ name: NAME_FIELD,
68
+ description: z
69
+ .string()
70
+ .min(1)
71
+ .describe('Short prose description woven into generation prompts (age, look, defining features, species…)'),
72
+ image_r2_key: z
73
+ .string()
74
+ .min(1)
75
+ .optional()
76
+ .describe('Reference image already in this connector\'s store: an `r2_key` from gemini_get_upload_url + PUT, or from a generation result\'s media[].r2_key'),
77
+ image_url: z.string().url().optional().describe('Reference image as a public https URL the SERVER fetches'),
78
+ },
79
+ }, async (args, extra) => {
80
+ const image = (await withProgressHeartbeat(extra, 'Loading the reference image', () => resolveLibraryImage(client, args, true)));
81
+ const record = await library.saveCharacter({ name: args.name, description: args.description, image });
82
+ return textResult({
83
+ saved: 'character',
84
+ name: record.name,
85
+ description: record.description,
86
+ mime_type: record.mime_type,
87
+ created_at: record.created_at,
88
+ updated_at: record.updated_at,
89
+ hint: USE_CHARACTERS_HINT,
90
+ });
91
+ });
92
+ server.registerTool('gemini_list_characters', {
93
+ description: 'List this account\'s saved characters (persistent reference library): names, descriptions and timestamps. ' +
94
+ USE_CHARACTERS_HINT,
95
+ annotations: { readOnlyHint: true, openWorldHint: false },
96
+ inputSchema: {},
97
+ }, async () => {
98
+ const characters = await library.listCharacters();
99
+ return textResult({
100
+ count: characters.length,
101
+ characters: characters.map((c) => ({
102
+ name: c.name,
103
+ description: c.description,
104
+ mime_type: c.mime_type,
105
+ created_at: c.created_at,
106
+ updated_at: c.updated_at,
107
+ })),
108
+ hint: characters.length
109
+ ? USE_CHARACTERS_HINT
110
+ : 'No saved characters yet. Save one with gemini_save_character (image via image_r2_key or image_url).',
111
+ });
112
+ });
113
+ server.registerTool('gemini_delete_character', {
114
+ description: 'Delete a saved character from this account\'s reference library. Generation calls naming it will then fail until it is re-saved.',
115
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
116
+ inputSchema: { name: NAME_FIELD, confirm: schemaConfirm },
117
+ }, async (args) => {
118
+ const gate = previewUnlessConfirmed(args.confirm, 'Delete a saved character (its reference image and description)', 'DELETE', `characters/${args.name}`);
119
+ if (gate)
120
+ return gate;
121
+ const existed = await library.deleteCharacter(args.name);
122
+ if (!existed) {
123
+ throw new McpToolError(`No saved character "${args.name}". List what exists with gemini_list_characters.`);
124
+ }
125
+ return textResult({ deleted: 'character', name: args.name });
126
+ });
127
+ server.registerTool('gemini_save_style', {
128
+ description: 'Save a named style preset to this account\'s persistent reference library: a reusable prompt fragment (and ' +
129
+ 'optionally a reference image) that a single word then applies to any generation. No expiry; re-saving a name replaces it. ' +
130
+ USE_STYLE_HINT,
131
+ annotations: { readOnlyHint: false, openWorldHint: false },
132
+ inputSchema: {
133
+ name: NAME_FIELD,
134
+ prompt_fragment: z
135
+ .string()
136
+ .min(1)
137
+ .describe('The style text appended to generation prompts, e.g. "bold cartoon style, thick outlines, saturated colors"'),
138
+ image_r2_key: z.string().min(1).optional().describe('Optional style reference image already in this connector\'s store (an r2_key)'),
139
+ image_url: z.string().url().optional().describe('Optional style reference image as a public https URL the SERVER fetches'),
140
+ },
141
+ }, async (args, extra) => {
142
+ const image = await withProgressHeartbeat(extra, 'Loading the style reference image', () => resolveLibraryImage(client, args, false));
143
+ const record = await library.saveStyle({ name: args.name, promptFragment: args.prompt_fragment, image });
144
+ return textResult({
145
+ saved: 'style',
146
+ name: record.name,
147
+ prompt_fragment: record.prompt_fragment,
148
+ ...(record.image_key ? { has_reference_image: true } : {}),
149
+ created_at: record.created_at,
150
+ updated_at: record.updated_at,
151
+ hint: USE_STYLE_HINT,
152
+ });
153
+ });
154
+ server.registerTool('gemini_list_styles', {
155
+ description: 'List this account\'s saved style presets (persistent reference library). ' + USE_STYLE_HINT,
156
+ annotations: { readOnlyHint: true, openWorldHint: false },
157
+ inputSchema: {},
158
+ }, async () => {
159
+ const styles = await library.listStyles();
160
+ return textResult({
161
+ count: styles.length,
162
+ styles: styles.map((s) => ({
163
+ name: s.name,
164
+ prompt_fragment: s.prompt_fragment,
165
+ ...(s.image_key ? { has_reference_image: true } : {}),
166
+ created_at: s.created_at,
167
+ updated_at: s.updated_at,
168
+ })),
169
+ hint: styles.length ? USE_STYLE_HINT : 'No saved styles yet. Save one with gemini_save_style.',
170
+ });
171
+ });
172
+ server.registerTool('gemini_delete_style', {
173
+ description: 'Delete a saved style preset from this account\'s reference library.',
174
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
175
+ inputSchema: { name: NAME_FIELD, confirm: schemaConfirm },
176
+ }, async (args) => {
177
+ const gate = previewUnlessConfirmed(args.confirm, 'Delete a saved style preset', 'DELETE', `styles/${args.name}`);
178
+ if (gate)
179
+ return gate;
180
+ const existed = await library.deleteStyle(args.name);
181
+ if (!existed) {
182
+ throw new McpToolError(`No saved style "${args.name}". List what exists with gemini_list_styles.`);
183
+ }
184
+ return textResult({ deleted: 'style', name: args.name });
185
+ });
186
+ }
@@ -3,7 +3,7 @@ import { McpToolError } from '@chrischall/mcp-utils';
3
3
  import { slugify, baseName } from '../images.js';
4
4
  import { resolveImageInputs } from '../inputs.js';
5
5
  import { DEFAULT_MUSIC_MODEL } from '../models.js';
6
- import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
6
+ import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
7
7
  import { fingerprintRequest } from '../jobs.js';
8
8
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
9
9
  const MUSIC_MODELS = ['lyria-3-clip-preview', 'lyria-3-pro-preview'];
@@ -39,6 +39,7 @@ export function registerMusicTools(server, client) {
39
39
  timeout_ms: timeoutMsSchema,
40
40
  idempotency_key: idempotencyKeySchema,
41
41
  async: asyncSchema,
42
+ max_wait_ms: maxWaitMsSchema,
42
43
  confirm: schemaConfirm,
43
44
  },
44
45
  }, async (args, extra) => {
@@ -67,7 +68,7 @@ export function registerMusicTools(server, client) {
67
68
  images_url: args.images_url, images_file_uris: args.images_file_uris,
68
69
  previous_interaction_id: previousInteractionId,
69
70
  });
70
- return client.session.jobs.dispatch({ toolName: 'gemini_music_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
71
+ return client.session.jobs.dispatch({ toolName: 'gemini_music_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
71
72
  const { inputs, report } = await resolveImageInputs(args, client);
72
73
  const r = await withProgressHeartbeat(extra, `Generating music (${model})`, () => client.generateMusic({
73
74
  input: args.prompt,
package/dist/tools/set.js CHANGED
@@ -3,12 +3,13 @@ import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveModel } from '../models.js';
4
4
  import { slugify, baseName } from '../images.js';
5
5
  import { resolveImageInputs } from '../inputs.js';
6
- import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
6
+ import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesR2KeysSchema, charactersSchema, styleSchema, resolveCharacterRefs, composePrompt, persistBundle, SET_URL_TTL_MS } from './shared.js';
7
7
  import { fingerprintRequest } from '../jobs.js';
8
8
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
9
9
  export function registerSetTools(server, client) {
10
10
  server.registerTool('gemini_image_set', {
11
- description: 'Generate a consistent SET of images: a master image from master_prompt, then one image per scene that references the master so the subject/style stays consistent. Provide `scenes` (explicit per-image prompts) OR `count` (variations of the master).',
11
+ description: 'Generate a consistent SET of images: a master image from master_prompt, then one image per scene that references the master so the subject/style stays consistent. Provide `scenes` (explicit per-image prompts) OR `count` (variations of the master). ' +
12
+ 'Scene generations run in parallel (reference_mode "master", the default). On the hosted connector: saved `characters` and a saved `style` can seed the whole set by name, multi-image results include a `bundle_url` zip of every image (one curl instead of N), and `max_wait_ms` returns a pollable job handle if the batch runs long.',
12
13
  annotations: { readOnlyHint: false, openWorldHint: true },
13
14
  inputSchema: {
14
15
  master_prompt: z.string().min(1).describe('Prompt for the master/reference image'),
@@ -19,6 +20,9 @@ export function registerSetTools(server, client) {
19
20
  master_images: z.array(z.string().min(1)).optional().describe('Reference image paths passed to the master generation call'),
20
21
  master_images_url: imagesUrlSchema('Reference images passed to the master AND to every scene call (fetched once)'),
21
22
  master_images_file_uris: imagesFileUrisSchema('Reference images passed to the master AND to every scene call'),
23
+ master_images_r2_keys: imagesR2KeysSchema('Reference images passed to the master AND to every scene call'),
24
+ characters: charactersSchema,
25
+ style: styleSchema,
22
26
  master_images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs for master generation. Last resort: prefer master_images_url or master_images_file_uris, which keep image bytes out of the conversation'),
23
27
  confirm: schemaConfirm,
24
28
  ...sharedImageSchema,
@@ -41,8 +45,9 @@ export function registerSetTools(server, client) {
41
45
  thinking_level: args.thinking_level, google_search: args.google_search,
42
46
  master_images: args.master_images, master_images_base64: args.master_images_base64, from_clipboard: args.from_clipboard,
43
47
  master_images_url: args.master_images_url, master_images_file_uris: args.master_images_file_uris,
48
+ master_images_r2_keys: args.master_images_r2_keys, characters: args.characters, style: args.style,
44
49
  });
45
- return client.session.jobs.dispatch({ toolName: 'gemini_image_set', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
50
+ return client.session.jobs.dispatch({ toolName: 'gemini_image_set', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
46
51
  const seed = pickSeed(args.seed);
47
52
  const cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search, timeoutMs: args.timeout_ms };
48
53
  const slug = args.basename ? baseName(args.basename) : slugify(args.master_prompt);
@@ -58,9 +63,22 @@ export function registerSetTools(server, client) {
58
63
  // - `masterOnly` — path / base64 / clipboard references. Master-only,
59
64
  // exactly as before these parameters existed, so pre-existing calls
60
65
  // still build byte-identical requests.
61
- const carried = await resolveImageInputs({ images_url: args.master_images_url, images_file_uris: args.master_images_file_uris }, client);
66
+ // Saved characters/style resolve first and fail fast on an unknown
67
+ // name, before anything billable. Their reference images join the
68
+ // CARRIED group — consistency references belong on every scene call.
69
+ const charRefs = await resolveCharacterRefs(client, args.characters, args.style);
70
+ // The master prompt gets the full library treatment (character
71
+ // preamble + style fragment). Scene prompts get only the style
72
+ // fragment: their first attached image is the master output, so the
73
+ // preamble's "attached in order" claim would be off by one there —
74
+ // the master image itself is what anchors identity scene to scene.
75
+ const masterPrompt = composePrompt(args.master_prompt, charRefs);
76
+ const styleOnly = { styleFragment: charRefs.styleFragment };
77
+ const carried = await resolveImageInputs({ images_url: args.master_images_url, images_file_uris: args.master_images_file_uris, images_r2_keys: args.master_images_r2_keys }, client);
78
+ /** What every SCENE call attaches (after its master/previous image). */
79
+ const carriedRefs = [...charRefs.inputs, ...carried.inputs];
62
80
  const masterOnly = await resolveImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard }, client);
63
- const masterRefInputs = [...masterOnly.inputs, ...carried.inputs];
81
+ const masterRefInputs = [...charRefs.inputs, ...masterOnly.inputs, ...carried.inputs];
64
82
  /** Scenes that failed, so one bad scene cannot lose the whole batch. */
65
83
  const failed = [];
66
84
  const report = carried.report || masterOnly.report
@@ -69,7 +87,7 @@ export function registerSetTools(server, client) {
69
87
  const named = [];
70
88
  const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
71
89
  const result = await client.generate({
72
- prompt: args.master_prompt,
90
+ prompt: masterPrompt,
73
91
  images: masterRefInputs.length > 0 ? masterRefInputs : undefined,
74
92
  seed,
75
93
  ...cfg,
@@ -99,7 +117,7 @@ export function registerSetTools(server, client) {
99
117
  let ref = master;
100
118
  for (let i = 0; i < scenePrompts.length; i++) {
101
119
  try {
102
- const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref, ...carried.inputs], seed: seed + i + 1, ...cfg });
120
+ const { images: [img] } = await client.generate({ prompt: composePrompt(scenePrompts[i], styleOnly), images: [ref, ...carriedRefs], seed: seed + i + 1, ...cfg });
103
121
  named.push({ image: img, base: sceneName(i) });
104
122
  // Chain mode anchors each scene on the previous OUTPUT, so a
105
123
  // failure must not advance the reference — the next scene
@@ -112,7 +130,7 @@ export function registerSetTools(server, client) {
112
130
  }
113
131
  }
114
132
  else {
115
- const settled = await Promise.allSettled(scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master, ...carried.inputs], seed: seed + i + 1, ...cfg }).then((r) => r.images[0])));
133
+ const settled = await Promise.allSettled(scenePrompts.map((p, i) => client.generate({ prompt: composePrompt(p, styleOnly), images: [master, ...carriedRefs], seed: seed + i + 1, ...cfg }).then((r) => r.images[0])));
116
134
  settled.forEach((outcome, i) => {
117
135
  if (outcome.status === 'fulfilled')
118
136
  named.push({ image: outcome.value, base: sceneName(i) });
@@ -130,6 +148,8 @@ export function registerSetTools(server, client) {
130
148
  meta.grounding = masterResult.grounding;
131
149
  if (report)
132
150
  meta.image_inputs = report;
151
+ if (charRefs.meta)
152
+ Object.assign(meta, charRefs.meta);
133
153
  if (failed.length) {
134
154
  meta.failed_scenes = failed;
135
155
  meta.partial =
@@ -142,7 +162,15 @@ export function registerSetTools(server, client) {
142
162
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length, persistsFiles: client.mediaSink?.persistsFiles });
143
163
  if (risk)
144
164
  meta.timeout_risk = risk;
145
- return emit(named, { ...args, sink: client.mediaSink }, meta);
165
+ // One zip of the whole set (object-storage sinks, >1 image) — the
166
+ // consumer runs ONE curl instead of N. Best-effort; per-image URLs and
167
+ // curl_hints below are unaffected.
168
+ if (!args.inline)
169
+ Object.assign(meta, await persistBundle(named, client.mediaSink, slug));
170
+ // A set is a batch someone comes back to: sign its links (and the
171
+ // bundle's) for ~7 days rather than the default ~48h. The sink clamps
172
+ // to the retention window; the disk sink ignores it entirely.
173
+ return emit(named, { ...args, sink: client.mediaSink, urlTtlMs: SET_URL_TTL_MS }, meta);
146
174
  });
147
175
  });
148
176
  }