@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,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,16 +63,31 @@ 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];
82
+ /** Scenes that failed, so one bad scene cannot lose the whole batch. */
83
+ const failed = [];
64
84
  const report = carried.report || masterOnly.report
65
85
  ? { ...masterOnly.report, ...carried.report }
66
86
  : undefined;
67
87
  const named = [];
68
88
  const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
69
89
  const result = await client.generate({
70
- prompt: args.master_prompt,
90
+ prompt: masterPrompt,
71
91
  images: masterRefInputs.length > 0 ? masterRefInputs : undefined,
72
92
  seed,
73
93
  ...cfg,
@@ -79,17 +99,44 @@ export function registerSetTools(server, client) {
79
99
  // 2. scene prompts (explicit, or N repeats of master_prompt for variations)
80
100
  const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
81
101
  const mode = args.reference_mode ?? 'master';
102
+ // A set is N+1 billed generations in one tools/call, and any one of
103
+ // them can fail — a safety filter on a single scene, a transient 5xx.
104
+ // `Promise.all` rejected the whole batch on the first failure, losing
105
+ // the master and every scene that HAD succeeded, and surfaced as an
106
+ // opaque "Error occurred during tool execution" with no indication of
107
+ // which scene or why. Failures are collected per scene instead.
108
+ const sceneName = (i) => `${slug}-${String(i + 1).padStart(2, '0')}`;
109
+ const record = (err, i) => {
110
+ failed.push({
111
+ scene: i + 1,
112
+ prompt: scenePrompts[i],
113
+ error: err instanceof Error ? err.message : String(err),
114
+ });
115
+ };
82
116
  if (mode === 'chain') {
83
117
  let ref = master;
84
118
  for (let i = 0; i < scenePrompts.length; i++) {
85
- const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref, ...carried.inputs], seed: seed + i + 1, ...cfg });
86
- named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` });
87
- ref = img;
119
+ try {
120
+ const { images: [img] } = await client.generate({ prompt: composePrompt(scenePrompts[i], styleOnly), images: [ref, ...carriedRefs], seed: seed + i + 1, ...cfg });
121
+ named.push({ image: img, base: sceneName(i) });
122
+ // Chain mode anchors each scene on the previous OUTPUT, so a
123
+ // failure must not advance the reference — the next scene
124
+ // continues from the last image that actually exists.
125
+ ref = img;
126
+ }
127
+ catch (err) {
128
+ record(err, i);
129
+ }
88
130
  }
89
131
  }
90
132
  else {
91
- const scenes = await Promise.all(scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master, ...carried.inputs], seed: seed + i + 1, ...cfg }).then((r) => r.images[0])));
92
- scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '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])));
134
+ settled.forEach((outcome, i) => {
135
+ if (outcome.status === 'fulfilled')
136
+ named.push({ image: outcome.value, base: sceneName(i) });
137
+ else
138
+ record(outcome.reason, i);
139
+ });
93
140
  }
94
141
  return result;
95
142
  });
@@ -101,12 +148,29 @@ export function registerSetTools(server, client) {
101
148
  meta.grounding = masterResult.grounding;
102
149
  if (report)
103
150
  meta.image_inputs = report;
151
+ if (charRefs.meta)
152
+ Object.assign(meta, charRefs.meta);
153
+ if (failed.length) {
154
+ meta.failed_scenes = failed;
155
+ meta.partial =
156
+ `${failed.length} of ${failed.length + named.length - 1} scene(s) failed; the master and the ` +
157
+ `${named.length - 1} scene(s) that succeeded are returned above. Re-run just the failed prompts rather than the whole set — ` +
158
+ 'each failure message is in failed_scenes.';
159
+ }
104
160
  // A set is master + N scenes in one tools/call — the most timeout-prone
105
161
  // tool. Effective image count drives the risk hint.
106
162
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length, persistsFiles: client.mediaSink?.persistsFiles });
107
163
  if (risk)
108
164
  meta.timeout_risk = risk;
109
- 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);
110
174
  });
111
175
  });
112
176
  }
@@ -1,9 +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
+ import { downloadFilename } from '../media-endpoint.js';
8
+ import { buildZip } from '../zip.js';
7
9
  /**
8
10
  * Fallback sink for call sites that pass no explicit one (every pre-existing
9
11
  * test, and any caller constructed before the sink seam existed). The stdio
@@ -54,6 +56,136 @@ export const asyncSchema = z
54
56
  .boolean()
55
57
  .optional()
56
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
+ }
57
189
  /**
58
190
  * Reference images the SERVER fetches, so the bytes never pass through the
59
191
  * model's context window. Defined once and reused under both the `images_url`
@@ -104,6 +236,7 @@ export const sharedImageSchema = {
104
236
  timeout_ms: timeoutMsSchema,
105
237
  idempotency_key: idempotencyKeySchema,
106
238
  async: asyncSchema,
239
+ max_wait_ms: maxWaitMsSchema,
107
240
  };
108
241
  /**
109
242
  * Turn the `video_url` / `video_path` tool args into the client's video opts.
@@ -267,6 +400,7 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
267
400
  const sink = opts.sink ?? DEFAULT_SINK;
268
401
  const persisted = await sink.persist(named.map((n) => ({ base: n.base, base64: n.media.base64, mimeType: n.media.mimeType })), {
269
402
  output_dir: opts.output_dir,
403
+ urlTtlMs: opts.urlTtlMs,
270
404
  });
271
405
  const refs = persisted.map((p) => p.ref);
272
406
  // `onWritten` (sidecars, the written-outputs set) is meaningful only when the
@@ -300,6 +434,12 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
300
434
  url: p.ref,
301
435
  ...(p.key ? { r2_key: p.key } : {}),
302
436
  ...(p.expiresAt ? { expires_at: p.expiresAt } : {}),
437
+ // A ready-to-run command, not a description of one. Models copy an
438
+ // exact string far more reliably than they compose one — and in a
439
+ // chat client this download-then-attach flow is the ONLY way the end
440
+ // user actually sees the image (inline MCP blocks and markdown embeds
441
+ // both render for the model and not for them).
442
+ ...(p.key ? { curl_hint: `curl -sS -o ${downloadFilename(p.key)} "${p.ref}"` } : {}),
303
443
  })),
304
444
  }
305
445
  : {};
@@ -313,6 +453,66 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
313
453
  export async function emit(named, opts, meta, onWritten) {
314
454
  return emitMedia(named.map((n) => ({ media: n.image, base: n.base })), 'image', opts, meta, onWritten);
315
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
+ }
316
516
  /**
317
517
  * Inputs that can only come off a local filesystem. On the hosted connector
318
518
  * (a Cloudflare Worker) none of them exist: