@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.
@@ -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
  }
@@ -1,10 +1,11 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, McpToolError, readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveVideoPath, videoMimeType } from '../images.js';
4
- import { IMAGE_URL_MAX_BYTES } from '../fetch-image.js';
5
- import { wholeMb } from '../bytes.js';
4
+ import { IMAGE_URL_MAX_BYTES, IMAGE_INLINE_MAX_BYTES } from '../fetch-image.js';
5
+ import { bytesToBase64, base64ToBytes, wholeMb } from '../bytes.js';
6
6
  import { createDiskSink } from '../storage/media.js';
7
7
  import { downloadFilename } from '../media-endpoint.js';
8
+ import { buildZip } from '../zip.js';
8
9
  /**
9
10
  * Fallback sink for call sites that pass no explicit one (every pre-existing
10
11
  * test, and any caller constructed before the sink seam existed). The stdio
@@ -55,6 +56,136 @@ export const asyncSchema = z
55
56
  .boolean()
56
57
  .optional()
57
58
  .describe('Run in the background and return a job_id immediately instead of the image, so a long (Pro/4K) generation cannot hit the host tools/call timeout (-32001). Poll gemini_get_result with the job_id to fetch the result (jobs are per-process and expire ~10 min after completion).');
59
+ /**
60
+ * Wait-then-hand-off budget shared by every generation tool: wait up to the
61
+ * budget for the in-band result, and past it return a `{ job_id }` handle
62
+ * (the work continues; the caller polls `gemini_get_result`). The middle
63
+ * ground between fully synchronous and `async: true`.
64
+ */
65
+ export const maxWaitMsSchema = z
66
+ .number()
67
+ .int()
68
+ .positive()
69
+ .max(600_000)
70
+ .optional()
71
+ .describe('Wait up to this many ms for the result; if generation is still running when the budget expires, return ' +
72
+ '{ job_id, status: "running" } immediately instead (poll gemini_get_result). Keeps fast results in-band while a ' +
73
+ 'slow batch can never trip the host tools/call timeout (-32001) — e.g. 20000 for multi-image sets. Ignored when async is set.');
74
+ /**
75
+ * Reference images already in THIS connector's object store, by `r2_key`.
76
+ * Hosted connector only — the server reads its own bucket (tenant-gated), so
77
+ * no bytes enter the conversation and no signed URL is needed.
78
+ */
79
+ export function imagesR2KeysSchema(label = 'Reference images') {
80
+ return z
81
+ .array(z.string().min(1))
82
+ .optional()
83
+ .describe(`${label} by r2_key from THIS connector's store: a signed upload (gemini_get_upload_url → curl PUT) or an earlier ` +
84
+ "generation's media[].r2_key. The server reads its own bucket directly — no bytes in the conversation, no signed URL, " +
85
+ 'no ~48h Files API expiry. Hosted connector only.');
86
+ }
87
+ /** Saved-character names attached to a generation (hosted connector only). */
88
+ export const charactersSchema = z
89
+ .array(z.string().min(1))
90
+ .max(8)
91
+ .optional()
92
+ .describe('Names of saved characters (see gemini_list_characters / gemini_save_character): each one\'s reference image and ' +
93
+ 'description are attached to the request automatically, keeping recurring subjects consistent without re-sending anything. ' +
94
+ 'Hosted connector only.');
95
+ /** Saved-style name applied to a generation (hosted connector only). */
96
+ export const styleSchema = z
97
+ .string()
98
+ .min(1)
99
+ .optional()
100
+ .describe('Name of a saved style preset (see gemini_list_styles / gemini_save_style): its prompt fragment — and reference image, ' +
101
+ 'if it has one — is applied to the request automatically. Hosted connector only.');
102
+ /**
103
+ * Turn `characters: ["finn", …]` / `style: "…"` into reference-image inputs
104
+ * plus prompt text, from the account's saved library.
105
+ *
106
+ * A name that is not saved fails BEFORE any billable call, and names what IS
107
+ * saved — a generation silently missing its reference is a wasted charge that
108
+ * looks like a model failure.
109
+ */
110
+ export async function resolveCharacterRefs(client, names, styleName) {
111
+ if (!names?.length && !styleName)
112
+ return { inputs: [] };
113
+ const library = client.library;
114
+ if (!library) {
115
+ throw new McpToolError('`characters` / `style` reference this account\'s saved library, which exists on the hosted connector only. ' +
116
+ 'On this server pass the reference images directly (images / images_url / images_file_uris) and put the style text in the prompt.');
117
+ }
118
+ const inputs = [];
119
+ const described = [];
120
+ // Same size rule as every other funnel: a large reference is promoted to a
121
+ // Files API reference rather than inlined — generateContent caps a whole
122
+ // request near 20MB, and a crew of full-resolution characters would hit it.
123
+ const toInput = async (image, displayName) => {
124
+ if (image.bytes.byteLength <= IMAGE_INLINE_MAX_BYTES) {
125
+ return { base64: bytesToBase64(image.bytes), mimeType: image.mimeType };
126
+ }
127
+ const uploaded = await client.uploadBytes(image.bytes, image.mimeType, displayName);
128
+ return { uri: uploaded.uri, mimeType: uploaded.mimeType };
129
+ };
130
+ for (const name of names ?? []) {
131
+ const record = await library.getCharacter(name);
132
+ const image = record && (await library.readCharacterImage(name));
133
+ if (!record || !image) {
134
+ const available = (await library.listCharacters()).map((c) => c.name);
135
+ throw new McpToolError(`No saved character "${name}". ` +
136
+ (available.length ? `Saved characters: ${available.join(', ')}. ` : 'None are saved yet. ') +
137
+ 'Save one with gemini_save_character.');
138
+ }
139
+ inputs.push(await toInput(image, name));
140
+ described.push(`${record.name} — ${record.description}`);
141
+ }
142
+ let styleFragment;
143
+ let styleImageAttached = false;
144
+ if (styleName) {
145
+ const record = await library.getStyle(styleName);
146
+ if (!record) {
147
+ const available = (await library.listStyles()).map((s) => s.name);
148
+ throw new McpToolError(`No saved style "${styleName}". ` +
149
+ (available.length ? `Saved styles: ${available.join(', ')}. ` : 'None are saved yet. ') +
150
+ 'Save one with gemini_save_style.');
151
+ }
152
+ styleFragment = record.prompt_fragment;
153
+ if (record.image_key) {
154
+ const image = await library.readStyleImage(styleName);
155
+ if (image) {
156
+ inputs.push(await toInput(image, styleName));
157
+ styleImageAttached = true;
158
+ }
159
+ }
160
+ }
161
+ // The style note is POSITIONAL, never "the final attached image": every
162
+ // caller appends the caller's own reference images (images / images_url /
163
+ // images_file_uris / images_r2_keys) AFTER these inputs, so a "final image"
164
+ // claim would point the model at the caller's last subject photo whenever
165
+ // characters/style are combined with direct references. These inputs always
166
+ // lead the attachment order, so index-based wording stays true regardless
167
+ // of what follows.
168
+ const characterLine = described.length
169
+ ? `Use the attached reference images for these characters, in order: ${described
170
+ .map((d, i) => `(${i + 1}) ${d}`)
171
+ .join('; ')}. Keep each character visually consistent with their reference image.` +
172
+ (styleImageAttached ? ` Attached image (${described.length + 1}) is a style reference, not a character.` : '')
173
+ : styleImageAttached
174
+ ? 'The first attached image is a style reference.'
175
+ : undefined;
176
+ const meta = {};
177
+ if (names?.length)
178
+ meta.characters = names;
179
+ if (styleName)
180
+ meta.style = styleName;
181
+ return { inputs, characterLine, styleFragment, meta: Object.keys(meta).length ? meta : undefined };
182
+ }
183
+ /** Compose the effective prompt from the caller's text + library lines. */
184
+ export function composePrompt(prompt, refs) {
185
+ return [refs.characterLine, prompt, refs.styleFragment ? `Style: ${refs.styleFragment}` : undefined]
186
+ .filter(Boolean)
187
+ .join('\n\n');
188
+ }
58
189
  /**
59
190
  * Reference images the SERVER fetches, so the bytes never pass through the
60
191
  * model's context window. Defined once and reused under both the `images_url`
@@ -105,6 +236,7 @@ export const sharedImageSchema = {
105
236
  timeout_ms: timeoutMsSchema,
106
237
  idempotency_key: idempotencyKeySchema,
107
238
  async: asyncSchema,
239
+ max_wait_ms: maxWaitMsSchema,
108
240
  };
109
241
  /**
110
242
  * Turn the `video_url` / `video_path` tool args into the client's video opts.
@@ -268,6 +400,7 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
268
400
  const sink = opts.sink ?? DEFAULT_SINK;
269
401
  const persisted = await sink.persist(named.map((n) => ({ base: n.base, base64: n.media.base64, mimeType: n.media.mimeType })), {
270
402
  output_dir: opts.output_dir,
403
+ urlTtlMs: opts.urlTtlMs,
271
404
  });
272
405
  const refs = persisted.map((p) => p.ref);
273
406
  // `onWritten` (sidecars, the written-outputs set) is meaningful only when the
@@ -320,6 +453,66 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
320
453
  export async function emit(named, opts, meta, onWritten) {
321
454
  return emitMedia(named.map((n) => ({ media: n.image, base: n.base })), 'image', opts, meta, onWritten);
322
455
  }
456
+ /**
457
+ * Signed-URL lifetime for MULTI-image results (and their bundle zip) on
458
+ * object-storage sinks: ~7 days rather than the default ~48h, clamped by the
459
+ * sink to the retention window. A set is a batch someone comes back to; its
460
+ * links dying in two days while the objects live seven served nobody.
461
+ */
462
+ export const SET_URL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
463
+ /**
464
+ * Total DECODED size cap for bundling a set into a zip. Zipping holds roughly
465
+ * 4× the set's bytes at peak (the decoded copies, the STORE zip, and the
466
+ * zip's own base64) on top of the base64 strings the result already carries —
467
+ * and a Cloudflare isolate gets ~128MB, where an OOM kill is uncatchable, so
468
+ * the try/catch in {@link persistBundle} cannot honor its best-effort promise
469
+ * against it. The guard runs on sizes ESTIMATED from base64 length, before a
470
+ * single byte is decoded.
471
+ */
472
+ export const BUNDLE_MAX_TOTAL_BYTES = 24 * 1024 * 1024;
473
+ /**
474
+ * Bundle a multi-image result into ONE downloadable zip and return the meta
475
+ * fields to merge into the result (`bundle_url` + a detailed `bundle` object).
476
+ *
477
+ * Object-storage sinks only, and only when there is more than one image — the
478
+ * point is turning N downloads into one `curl`, which a single image already
479
+ * is. Best-effort: a result that cost N billable generations must never fail
480
+ * because zipping or storing the bundle did — but a skipped bundle is SAID
481
+ * (`bundle_skipped`), never silently absent, so the caller falls back to the
482
+ * per-image URLs instead of hunting for a field that will never appear.
483
+ */
484
+ export async function persistBundle(named, sink, base) {
485
+ if (!sink || sink.persistsFiles || named.length < 2)
486
+ return {};
487
+ const totalBytes = named.reduce((sum, n) => sum + Math.floor((n.image.base64.length * 3) / 4), 0);
488
+ if (totalBytes > BUNDLE_MAX_TOTAL_BYTES) {
489
+ return {
490
+ bundle_skipped: `The set totals ~${wholeMb(totalBytes)}MB decoded, over the ${wholeMb(BUNDLE_MAX_TOTAL_BYTES)}MB bundling cap ` +
491
+ '(zipping peaks at ~4x that in Worker memory). Download the images individually via their media urls / curl_hints.',
492
+ };
493
+ }
494
+ try {
495
+ const { mediaExt } = await import('../images.js');
496
+ const zip = buildZip(named.map((n) => ({ name: `${n.base}.${mediaExt(n.image.mimeType)}`, bytes: base64ToBytes(n.image.base64) })));
497
+ const [persisted] = await sink.persist([{ base: `${base}-bundle`, base64: bytesToBase64(zip), mimeType: 'application/zip' }], { urlTtlMs: SET_URL_TTL_MS });
498
+ if (!persisted || persisted.unavailable) {
499
+ return { bundle_skipped: 'The bundle zip could not be stored; the images themselves are unaffected — download them individually via their media urls / curl_hints.' };
500
+ }
501
+ return {
502
+ bundle_url: persisted.ref,
503
+ bundle: {
504
+ url: persisted.ref,
505
+ ...(persisted.key ? { r2_key: persisted.key } : {}),
506
+ ...(persisted.expiresAt ? { expires_at: persisted.expiresAt } : {}),
507
+ files: named.map((n) => n.base),
508
+ ...(persisted.key ? { curl_hint: `curl -sS -o ${downloadFilename(persisted.key)} "${persisted.ref}"` } : {}),
509
+ },
510
+ };
511
+ }
512
+ catch {
513
+ return { bundle_skipped: 'Bundling failed; the images themselves are unaffected — download them individually via their media urls / curl_hints.' };
514
+ }
515
+ }
323
516
  /**
324
517
  * Inputs that can only come off a local filesystem. On the hosted connector
325
518
  * (a Cloudflare Worker) none of them exist: