@chrischall/gemini-mcp 1.4.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +64 -1
- package/dist/bundle.js +334 -30
- package/dist/client.js +24 -3
- 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/generate.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readEnvVar } from '@chrischall/mcp-utils';
|
|
|
3
3
|
import { resolveModel } from '../models.js';
|
|
4
4
|
import { slugify, baseName } from '../images.js';
|
|
5
5
|
import { resolveImageInputs, requireImageInput } from '../inputs.js';
|
|
6
|
-
import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
|
|
6
|
+
import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesR2KeysSchema, charactersSchema, styleSchema, resolveCharacterRefs, composePrompt } from './shared.js';
|
|
7
7
|
import { fingerprintRequest } from '../jobs.js';
|
|
8
8
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
9
9
|
const GENERATE_ENDPOINT = '/v1beta/models/{model}:generateContent';
|
|
@@ -24,7 +24,10 @@ export function registerGenerateTools(server, client) {
|
|
|
24
24
|
images: z.array(z.string().min(1)).optional().describe('Paths to reference input images (image-conditioned generation)'),
|
|
25
25
|
images_url: imagesUrlSchema(),
|
|
26
26
|
images_file_uris: imagesFileUrisSchema(),
|
|
27
|
+
images_r2_keys: imagesR2KeysSchema(),
|
|
27
28
|
images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation'),
|
|
29
|
+
characters: charactersSchema,
|
|
30
|
+
style: styleSchema,
|
|
28
31
|
video_url: z.string().url().optional().describe('Public YouTube URL (or a previously uploaded Files API uri) as a video reference (video→image; use a Flash model e.g. gemini-3.1-flash-image)'),
|
|
29
32
|
video_path: videoPathSchema,
|
|
30
33
|
confirm: schemaConfirm,
|
|
@@ -49,15 +52,22 @@ export function registerGenerateTools(server, client) {
|
|
|
49
52
|
model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
|
|
50
53
|
thinking_level: args.thinking_level, google_search: args.google_search, count,
|
|
51
54
|
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
52
|
-
images_url: args.images_url, images_file_uris: args.images_file_uris,
|
|
55
|
+
images_url: args.images_url, images_file_uris: args.images_file_uris, images_r2_keys: args.images_r2_keys,
|
|
56
|
+
characters: args.characters, style: args.style,
|
|
53
57
|
video_url: args.video_url, video_path: args.video_path,
|
|
54
58
|
});
|
|
55
|
-
return client.session.jobs.dispatch({ toolName: 'gemini_image_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
59
|
+
return client.session.jobs.dispatch({ toolName: 'gemini_image_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
|
|
56
60
|
const seed = pickSeed(args.seed);
|
|
57
61
|
const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
|
|
62
|
+
// Saved characters/style resolve first (and fail fast on an unknown
|
|
63
|
+
// name — before anything billable), so their reference images lead the
|
|
64
|
+
// input order the prompt preamble describes.
|
|
65
|
+
const charRefs = await resolveCharacterRefs(client, args.characters, args.style);
|
|
66
|
+
const prompt = composePrompt(args.prompt, charRefs);
|
|
58
67
|
// Resolved ONCE, before the count loop: a URL is fetched (and a large
|
|
59
68
|
// one uploaded) a single time no matter how many images are generated.
|
|
60
|
-
const { inputs:
|
|
69
|
+
const { inputs: directInputs, report } = await withProgressHeartbeat(extra, 'Resolving reference images', () => resolveImageInputs(args, client));
|
|
70
|
+
const refInputs = [...charRefs.inputs, ...directInputs];
|
|
61
71
|
// Upload ONCE (before the count loop) — every generate call references
|
|
62
72
|
// the same files/… uri.
|
|
63
73
|
const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args, client));
|
|
@@ -79,7 +89,7 @@ export function registerGenerateTools(server, client) {
|
|
|
79
89
|
// Distinct seed per image (seed+0 for the single-image case == echoed seed)
|
|
80
90
|
// so count>1 yields N *different* images, not N duplicates.
|
|
81
91
|
const result = await client.generate({
|
|
82
|
-
prompt
|
|
92
|
+
prompt,
|
|
83
93
|
images: refInputs.length > 0 ? refInputs : undefined,
|
|
84
94
|
model: args.model,
|
|
85
95
|
aspectRatio: args.aspect_ratio,
|
|
@@ -113,6 +123,8 @@ export function registerGenerateTools(server, client) {
|
|
|
113
123
|
meta.grounding = capturedGrounding;
|
|
114
124
|
if (report)
|
|
115
125
|
meta.image_inputs = report;
|
|
126
|
+
if (charRefs.meta)
|
|
127
|
+
Object.assign(meta, charRefs.meta);
|
|
116
128
|
if (failed.length) {
|
|
117
129
|
meta.failed_images = failed;
|
|
118
130
|
meta.partial =
|
|
@@ -139,14 +151,20 @@ export function registerGenerateTools(server, client) {
|
|
|
139
151
|
images: z.array(z.string().min(1)).optional().describe('Paths to input image file(s) (1 = edit, 2+ = compose)'),
|
|
140
152
|
images_url: imagesUrlSchema('Input images'),
|
|
141
153
|
images_file_uris: imagesFileUrisSchema('Input images'),
|
|
154
|
+
images_r2_keys: imagesR2KeysSchema('Input images'),
|
|
142
155
|
images_base64: z.array(z.string().min(1)).optional().describe('Input images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation'),
|
|
156
|
+
characters: charactersSchema,
|
|
157
|
+
style: styleSchema,
|
|
143
158
|
filename: z.string().optional().describe('Base filename for the output image (extension stripped; default: slugified prompt)'),
|
|
144
159
|
confirm: schemaConfirm,
|
|
145
160
|
...sharedImageSchema,
|
|
146
161
|
},
|
|
147
162
|
}, async (args, extra) => {
|
|
148
163
|
assertLocalInputsAvailable(client.mediaSink, args);
|
|
149
|
-
|
|
164
|
+
// Saved characters count as input images for the required-input check —
|
|
165
|
+
// the library attaches their references inside the handler.
|
|
166
|
+
if (!args.characters?.length)
|
|
167
|
+
requireImageInput(args);
|
|
150
168
|
// Confirm-gate local file inputs (see gemini_image_generate). base64 /
|
|
151
169
|
// clipboard inputs are not local-path reads and pass through ungated.
|
|
152
170
|
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
|
|
@@ -157,14 +175,18 @@ export function registerGenerateTools(server, client) {
|
|
|
157
175
|
model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
|
|
158
176
|
thinking_level: args.thinking_level, google_search: args.google_search,
|
|
159
177
|
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
160
|
-
images_url: args.images_url, images_file_uris: args.images_file_uris,
|
|
178
|
+
images_url: args.images_url, images_file_uris: args.images_file_uris, images_r2_keys: args.images_r2_keys,
|
|
179
|
+
characters: args.characters, style: args.style,
|
|
161
180
|
});
|
|
162
|
-
return client.session.jobs.dispatch({ toolName: 'gemini_image_edit', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
181
|
+
return client.session.jobs.dispatch({ toolName: 'gemini_image_edit', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
|
|
163
182
|
const seed = pickSeed(args.seed);
|
|
164
183
|
const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
|
|
165
|
-
const
|
|
184
|
+
const charRefs = await resolveCharacterRefs(client, args.characters, args.style);
|
|
185
|
+
const prompt = composePrompt(args.prompt, charRefs);
|
|
186
|
+
const { inputs: directInputs, report } = await withProgressHeartbeat(extra, 'Resolving input images', () => resolveImageInputs(args, client));
|
|
187
|
+
const inputs = [...charRefs.inputs, ...directInputs];
|
|
166
188
|
const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client.generate({
|
|
167
|
-
prompt
|
|
189
|
+
prompt,
|
|
168
190
|
images: inputs,
|
|
169
191
|
model: args.model,
|
|
170
192
|
aspectRatio: args.aspect_ratio,
|
|
@@ -182,6 +204,8 @@ export function registerGenerateTools(server, client) {
|
|
|
182
204
|
meta.grounding = result.grounding;
|
|
183
205
|
if (report)
|
|
184
206
|
meta.image_inputs = report;
|
|
207
|
+
if (charRefs.meta)
|
|
208
|
+
Object.assign(meta, charRefs.meta);
|
|
185
209
|
meta.hint = REFINE_HINT;
|
|
186
210
|
const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: client.mediaSink?.persistsFiles });
|
|
187
211
|
if (risk)
|
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
|
}
|