@chrischall/gemini-mcp 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +64 -1
- package/dist/bundle.js +330 -27
- package/dist/client.js +6 -0
- package/dist/inputs.js +37 -1
- package/dist/jobs.js +46 -2
- package/dist/library.js +151 -0
- package/dist/media-cleanup.js +4 -1
- package/dist/media-endpoint.js +6 -0
- package/dist/media-url.js +2 -2
- package/dist/put-endpoint.js +122 -0
- package/dist/storage/media.js +23 -12
- package/dist/tools/generate.js +34 -10
- package/dist/tools/interact.js +3 -2
- package/dist/tools/library.js +186 -0
- package/dist/tools/music.js +3 -2
- package/dist/tools/set.js +37 -9
- package/dist/tools/shared.js +195 -2
- package/dist/tools/uploads.js +69 -0
- package/dist/tools/video.js +3 -2
- package/dist/upload-url.js +138 -0
- package/dist/version.js +1 -1
- package/dist/zip.js +113 -0
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/tools/interact.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/tools/music.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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, ...
|
|
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, ...
|
|
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
|
-
|
|
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
|
}
|
package/dist/tools/shared.js
CHANGED
|
@@ -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:
|