@chrischall/gemini-mcp 1.12.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,8 @@ 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, reportShape, resolveAspectRatio, orientationSchema, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
8
+ import { emit, reportShape, resolveAspectRatio, orientationSchema, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesBase64Schema } from './shared.js';
9
+ import { bytesToBase64 } from '../bytes.js';
9
10
  import { fingerprintRequest } from '../jobs.js';
10
11
  import { attachCost } from '../pricing.js';
11
12
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
@@ -36,6 +37,35 @@ function splitReattachedOutputs(images, writtenOutputs) {
36
37
  }
37
38
  return { kept, dropped };
38
39
  }
40
+ /**
41
+ * The image a given interaction produced, off disk, as inline input.
42
+ *
43
+ * `refs` is what the result reports as `reanchored_on`, so it is the caller's
44
+ * own vocabulary in each deployment: absolute paths here, object keys below.
45
+ */
46
+ async function reanchorFromDisk(outputDir, interactionId) {
47
+ const refs = await findInteractionImages(outputDir, interactionId);
48
+ return { refs, carried: await Promise.all(refs.map((p) => readImageAsInline(p))) };
49
+ }
50
+ /** The same, from the object store, for a deployment with no filesystem. */
51
+ async function reanchorFromStore(client, interactionId) {
52
+ try {
53
+ const found = await client.mediaSink?.findByInteraction?.(interactionId);
54
+ if (!found)
55
+ return { refs: [], carried: [] };
56
+ const stored = await client.readStoredMedia(found.key);
57
+ return {
58
+ refs: [found.key],
59
+ carried: [{ base64: bytesToBase64(stored.bytes), mimeType: stored.mimeType }],
60
+ };
61
+ }
62
+ catch {
63
+ // Recovery is best-effort: a store that cannot answer leaves the caller
64
+ // with the original 404, which is the honest outcome — never a re-anchor
65
+ // onto something else.
66
+ return { refs: [], carried: [] };
67
+ }
68
+ }
39
69
  export function registerInteractTools(server, client) {
40
70
  // The sidecar/re-anchor half of this description is only true where there is
41
71
  // a filesystem. Describing it anyway on the hosted connector would tell the
@@ -43,23 +73,21 @@ export function registerInteractTools(server, client) {
43
73
  // description is built from the sink's capability, not hardcoded.
44
74
  const onDiskRuntime = client.mediaSink?.persistsFiles ?? true;
45
75
  const recoveryDescription = onDiskRuntime
46
- ? 'If a call times out on the client side, the generation usually still completes: the image plus a ' +
47
- '`<image>.json` sidecar recording its interaction id land in the output dir, and `continue_last: true` ' +
48
- 'still resumes that interaction — check the output dir before re-issuing (a re-issue is a second billable generation). ' +
49
- 'If a chained call 404s, this tool re-anchors itself on the prior output image and re-issues un-chained: ' +
50
- 'success is reported as `chain_recovered` (the chain was the problem, and you get your image anyway); ' +
51
- 'a second 404 is reported as the interaction id NOT being the cause (check the model id / files uri instead). '
52
- : 'On this connector each result returns an image URL you can open or share directly (see `media[].url`) — show it ' +
53
- 'to the user rather than assuming they can see the image. There is no filesystem, so no output dir and no ' +
54
- '`<image>.json` sidecar: a lost response cannot be recovered from disk and a 404 on a chained call cannot be ' +
55
- 're-anchored automatically. Capture the returned `interaction_id` from every result, and use `async: true` + ' +
56
- 'gemini_get_result for long generations. ';
76
+ ? 'If a call times out client-side the generation usually still completes: the image and an `<image>.json` sidecar ' +
77
+ 'holding its interaction id land in the output dir, and `continue_last: true` still resumes it — look there before ' +
78
+ 're-issuing, which bills a second generation. A chained 404 is re-anchored on the prior output and re-issued ' +
79
+ 'un-chained (`chain_recovered`); a second 404 means the interaction id was not the cause — check the model id / ' +
80
+ 'files uri. '
81
+ : 'Each result returns an image URL to open or share (`media[].url`) — show it to the user rather than assuming they ' +
82
+ 'can see the image. There is no output dir here, but a lost response is still recoverable: the interaction id is ' +
83
+ 'stored beside each image, so `continue_last: true` survives a restart and a chained 404 is re-anchored on the ' +
84
+ 'image that interaction produced (reported as `chain_recovered`). gemini_list_recent_media shows what each stored ' +
85
+ 'image was. Give long generations a `max_wait_ms` budget. ';
57
86
  server.registerTool('gemini_interact', {
58
- description: 'Preferred tool for iterative or multi-step refinement of a single image — ' +
59
- "multi-turn generation/editing via Gemini's Interactions API. " +
60
- 'To refine, capture the returned interaction `id` and pass it as `previous_interaction_id` ' +
61
- 'on the next call — do NOT start a new interaction or re-upload the image for each tweak. ' +
62
- '`continue_last: true` chains from this session\'s most recent interaction without threading the id. ' +
87
+ description: 'Preferred tool for iterative refinement of a single image — multi-turn generation and editing via the ' +
88
+ 'Interactions API. To refine, pass the returned interaction `id` as `previous_interaction_id` on the next call; ' +
89
+ 'do NOT start a new interaction or re-send the image for each tweak. `continue_last: true` chains from this ' +
90
+ "session's most recent interaction without threading the id. " +
63
91
  recoveryDescription +
64
92
  'Output is JPEG.',
65
93
  annotations: { readOnlyHint: false, openWorldHint: true },
@@ -79,11 +107,7 @@ export function registerInteractTools(server, client) {
79
107
  .describe(`Paths to reference input images. ${NEW_REFERENCES_ONLY}`),
80
108
  images_url: imagesUrlSchema(`Reference images. ${NEW_REFERENCES_ONLY} Given`),
81
109
  images_file_uris: imagesFileUrisSchema(`Reference images. ${NEW_REFERENCES_ONLY} Given`),
82
- images_base64: z
83
- .array(z.string().min(1))
84
- .optional()
85
- .describe(`Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, ` +
86
- `which keep image bytes out of the conversation. ${NEW_REFERENCES_ONLY}`),
110
+ images_base64: imagesBase64Schema(`Reference images. ${NEW_REFERENCES_ONLY} Given`),
87
111
  model: z
88
112
  .string()
89
113
  .optional()
@@ -153,15 +177,23 @@ export function registerInteractTools(server, client) {
153
177
  let continuedFromSidecar = false;
154
178
  if (!previousInteractionId && args.continue_last) {
155
179
  previousInteractionId = client.session.lastInteractionId;
156
- if (!previousInteractionId && onDisk) {
157
- previousInteractionId = await latestInteractionId(resolveOutputDir(args.output_dir));
180
+ if (!previousInteractionId) {
181
+ // Both branches answer the same question from the same kind of
182
+ // record — an `<image>.json` on disk, an object beside the media in
183
+ // the store — so a restart no longer ends a chain on either.
184
+ previousInteractionId = onDisk
185
+ ? await latestInteractionId(resolveOutputDir(args.output_dir))
186
+ // 'image' explicitly: video and music write records too, and a
187
+ // chained Lyria call is a documented 400. Resuming the wrong
188
+ // medium's chain is worse than not resuming.
189
+ : await client.mediaSink?.latestInteractionId?.('image');
158
190
  continuedFromSidecar = previousInteractionId !== undefined;
159
191
  }
160
192
  if (!previousInteractionId) {
161
193
  throw new McpToolError('No previous interaction to continue in this session.', {
162
194
  hint: onDisk
163
195
  ? 'Call gemini_interact once without continue_last first, or pass an explicit previous_interaction_id.'
164
- : 'Call gemini_interact once without continue_last first, or pass an explicit previous_interaction_id. (This hosted connector has no filesystem, so there are no <image>.json sidecars to recover an id from across restarts.)',
196
+ : 'Call gemini_interact once without continue_last first, or pass an explicit previous_interaction_id. (Nothing this connector has stored carries an interaction id to resume from.)',
165
197
  });
166
198
  }
167
199
  }
@@ -231,14 +263,15 @@ export function registerInteractTools(server, client) {
231
263
  // probe itself could not be completed.
232
264
  if (err.chainExists === true)
233
265
  throw err;
234
- // Re-anchoring needs the prior output image, which only the sidecar
235
- // index can locate — and that index lives on disk.
236
- if (!onDisk)
237
- throw err;
238
- const reanchorOn = await findInteractionImages(resolveOutputDir(args.output_dir), err.previousInteractionId);
266
+ // Re-anchoring needs the image that interaction produced, located
267
+ // by ID — never "the newest image". On disk that index is the
268
+ // `<image>.json` sidecars; hosted it is the record beside each
269
+ // stored object. Same rule either way: no match, no guess.
270
+ const { refs: reanchorOn, carried } = onDisk
271
+ ? await reanchorFromDisk(resolveOutputDir(args.output_dir), err.previousInteractionId)
272
+ : await reanchorFromStore(client, err.previousInteractionId);
239
273
  if (!reanchorOn.length)
240
274
  throw err;
241
- const carried = await Promise.all(reanchorOn.map((p) => readImageAsInline(p)));
242
275
  try {
243
276
  const probe = await client.interact({ ...callOpts, images: [...carried, ...inputs], previousInteractionId: undefined });
244
277
  chainRecovered = { expired_interaction_id: err.previousInteractionId, reanchored_on: reanchorOn };
@@ -298,7 +331,7 @@ export function registerInteractTools(server, client) {
298
331
  // writeImage returns already-resolved absolute paths. `emit` only
299
332
  // invokes this callback for a filesystem-backed sink, so there is no
300
333
  // branch here claiming a sidecar the hosted connector cannot write.
301
- return emit(named, { ...args, sink: client.mediaSink }, meta, async (paths) => {
334
+ return emit(named, { ...args, sink: client.mediaSink, sidecar: { prompt: args.input } }, meta, async (paths) => {
302
335
  for (const p of paths)
303
336
  client.session.writtenOutputs.add(p);
304
337
  // Sidecar per image so the interaction id survives a lost MCP response
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { McpToolError, textResult } from '@chrischall/mcp-utils';
2
+ import { McpToolError, minifiedResult } from '@chrischall/mcp-utils';
3
3
  import { TERMINAL_INTERACTION_STATUSES } from '../client.js';
4
4
  import { emitMedia } from './shared.js';
5
5
  import { slugify } from '../images.js';
@@ -128,7 +128,7 @@ function recoveryNote(recordedError) {
128
128
  }
129
129
  /** The generation outlived its executor and is not finished yet. */
130
130
  function stillRunning(jobId, interactionId) {
131
- return textResult({
131
+ return minifiedResult({
132
132
  job_id: jobId,
133
133
  status: 'running',
134
134
  interaction_id: interactionId,
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { McpToolError, textResult } from '@chrischall/mcp-utils';
2
+ import { McpToolError, minifiedResult } from '@chrischall/mcp-utils';
3
3
  import { LIBRARY_NAME_PATTERN } from '../library.js';
4
4
  import { acceptableUploadType } from '../upload-url.js';
5
5
  import { withProgressHeartbeat } from './shared.js';
@@ -79,7 +79,7 @@ export function registerLibraryTools(server, client) {
79
79
  }, async (args, extra) => {
80
80
  const image = (await withProgressHeartbeat(extra, 'Loading the reference image', () => resolveLibraryImage(client, args, true)));
81
81
  const record = await library.saveCharacter({ name: args.name, description: args.description, image });
82
- return textResult({
82
+ return minifiedResult({
83
83
  saved: 'character',
84
84
  name: record.name,
85
85
  description: record.description,
@@ -96,7 +96,7 @@ export function registerLibraryTools(server, client) {
96
96
  inputSchema: {},
97
97
  }, async () => {
98
98
  const characters = await library.listCharacters();
99
- return textResult({
99
+ return minifiedResult({
100
100
  count: characters.length,
101
101
  characters: characters.map((c) => ({
102
102
  name: c.name,
@@ -122,7 +122,7 @@ export function registerLibraryTools(server, client) {
122
122
  if (!existed) {
123
123
  throw new McpToolError(`No saved character "${args.name}". List what exists with gemini_list_characters.`);
124
124
  }
125
- return textResult({ deleted: 'character', name: args.name });
125
+ return minifiedResult({ deleted: 'character', name: args.name });
126
126
  });
127
127
  server.registerTool('gemini_save_style', {
128
128
  description: 'Save a named style preset to this account\'s persistent reference library: a reusable prompt fragment (and ' +
@@ -141,7 +141,7 @@ export function registerLibraryTools(server, client) {
141
141
  }, async (args, extra) => {
142
142
  const image = await withProgressHeartbeat(extra, 'Loading the style reference image', () => resolveLibraryImage(client, args, false));
143
143
  const record = await library.saveStyle({ name: args.name, promptFragment: args.prompt_fragment, image });
144
- return textResult({
144
+ return minifiedResult({
145
145
  saved: 'style',
146
146
  name: record.name,
147
147
  prompt_fragment: record.prompt_fragment,
@@ -157,7 +157,7 @@ export function registerLibraryTools(server, client) {
157
157
  inputSchema: {},
158
158
  }, async () => {
159
159
  const styles = await library.listStyles();
160
- return textResult({
160
+ return minifiedResult({
161
161
  count: styles.length,
162
162
  styles: styles.map((s) => ({
163
163
  name: s.name,
@@ -181,6 +181,6 @@ export function registerLibraryTools(server, client) {
181
181
  if (!existed) {
182
182
  throw new McpToolError(`No saved style "${args.name}". List what exists with gemini_list_styles.`);
183
183
  }
184
- return textResult({ deleted: 'style', name: args.name });
184
+ return minifiedResult({ deleted: 'style', name: args.name });
185
185
  });
186
186
  }
@@ -1,10 +1,10 @@
1
- import { textResult } from '@chrischall/mcp-utils';
1
+ import { minifiedResult } from '@chrischall/mcp-utils';
2
2
  export function registerModelTools(server, client) {
3
3
  server.registerTool('gemini_list_models', {
4
4
  description: 'List the Gemini image-generation models available to your API key (Nano Banana / Nano Banana Pro family), and the current default model.',
5
5
  annotations: { readOnlyHint: true },
6
6
  }, async () => {
7
7
  const models = await client.listModels();
8
- return textResult({ default: client.defaultModel(), models });
8
+ return minifiedResult({ default: client.defaultModel(), models });
9
9
  });
10
10
  }
@@ -1,42 +1,46 @@
1
1
  import { z } from 'zod';
2
- import { McpToolError } from '@chrischall/mcp-utils';
3
2
  import { slugify, baseName } from '../images.js';
4
3
  import { resolveImageInputs } from '../inputs.js';
5
4
  import { DEFAULT_MUSIC_MODEL } from '../models.js';
6
- import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
5
+ import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesBase64Schema } from './shared.js';
7
6
  import { fingerprintRequest } from '../jobs.js';
8
7
  import { attachCost } from '../pricing.js';
9
8
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
10
- const MUSIC_MODELS = ['lyria-3-clip-preview', 'lyria-3-pro-preview'];
11
- const AUDIO_FORMATS = ['mp3', 'wav'];
12
- /** WAV is Lyria-3-Pro-only (docs); reject it on the clip model up front. */
13
- function isProModel(model) { return /pro/i.test(model); }
14
- // The most-recent music interaction id (for continue_last) lives on
15
- // `client.session`, NOT at module scope: one Cloudflare isolate serves many
16
- // authenticated connector sessions, so a module-level id would let one user's
17
- // continue_last resume another user's interaction under their own key.
18
- // See src/session.ts.
9
+ const MUSIC_MODELS = ['lyria-3-clip-preview', 'lyria-3.5', 'lyria-3-pro-preview'];
10
+ /**
11
+ * There is no output-format parameter, and there is no chaining.
12
+ *
13
+ * Both were docs-derived and both were wrong (probed live 2026-09-09):
14
+ * `response_format.audio_format` is not a field — every Lyria model answers
15
+ * `400 Unknown parameter 'audio_format' at 'response_format'` — and its real
16
+ * name, `mime_type`, currently refuses every value but MP3 per-model
17
+ * (`Audio MIME type AUDIO_WAV is not supported for models/lyria-3.5`). A
18
+ * chained call dies inside the model, on the track it is handed back:
19
+ * `400 Unsupported input mime type for this model: audio/s16le`.
20
+ *
21
+ * So neither is exposed. A parameter whose every non-default value is a 400 is
22
+ * worse than an absent one: it costs schema tokens on every request and spends
23
+ * a caller's turn discovering it does not work.
24
+ */
19
25
  export function registerMusicTools(server, client) {
20
26
  server.registerTool('gemini_music_generate', {
21
- description: 'Generate music from a text prompt (mood, genre, instruments, structure, or lyrics inline) via a Lyria model ' +
22
- '(preview): lyria-3-clip-preview (~30s clips, default) or lyria-3-pro-preview (longer, WAV-capable). Written to ' +
23
- 'disk as MP3/WAV (or returned inline). Runs long — use `async: true` + gemini_get_result, or raise `timeout_ms`. ' +
24
- 'Preview model: needs a funded account.',
27
+ description: 'Generate music from a text prompt (mood, genre, instruments, structure, or lyrics inline) via a Lyria model: ' +
28
+ 'lyria-3-clip-preview (30s instrumental clip, default, cheapest), lyria-3.5 (full-length song with vocals) or ' +
29
+ 'lyria-3-pro-preview (longer-form). Output is MP3, written to disk (or returned inline). Single-turn: a track ' +
30
+ 'cannot be refined by a follow-up call, so put the whole brief in the prompt. Runs long — give it a `max_wait_ms` ' +
31
+ 'budget (or `async: true` + gemini_get_result on a local install), or raise `timeout_ms`. Needs a funded account.',
25
32
  annotations: { readOnlyHint: false, openWorldHint: true },
26
33
  inputSchema: {
27
34
  prompt: z.string().min(1).describe('Description of the music: mood, genre, instruments, tempo, structure, or lyrics'),
28
- model: z.enum(MUSIC_MODELS).optional().describe(`Lyria model (default: ${DEFAULT_MUSIC_MODEL}). Pro is longer-form and supports WAV.`),
29
- audio_format: z.enum(AUDIO_FORMATS).optional().describe('Output format (default mp3). wav is lyria-3-pro-preview-only.'),
35
+ model: z.enum(MUSIC_MODELS).optional().describe(`Lyria model (default: ${DEFAULT_MUSIC_MODEL} — 30s, $0.04). lyria-3.5 and lyria-3-pro-preview run minutes-long at $0.08.`),
30
36
  images: z.array(z.string().min(1)).optional().describe('Optional reference image path(s) to condition the music'),
31
37
  images_url: imagesUrlSchema('Reference images'),
32
38
  images_file_uris: imagesFileUrisSchema('Reference images'),
33
- 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'),
39
+ images_base64: imagesBase64Schema(),
34
40
  from_clipboard: z.boolean().optional().describe('Use the image currently on the macOS clipboard as a reference'),
35
41
  filename: z.string().optional().describe('Base filename for the output audio (extension stripped; default: slugified prompt)'),
36
42
  output_dir: z.string().optional().describe('Directory to write audio to (default: $GEMINI_OUTPUT_DIR or cwd)'),
37
- inline: z.boolean().optional().describe('Return base64 audio inline instead of writing to disk'),
38
- previous_interaction_id: z.string().optional().describe('Interaction id to continue from'),
39
- continue_last: z.boolean().optional().describe('Continue from the most recent music interaction this server created (explicit previous_interaction_id wins)'),
43
+ inline: z.boolean().optional().describe('Return base64 audio inline instead of writing to disk. A 30s track is ~1.9MB of base64 — the default hands back a path instead'),
40
44
  background: z.boolean().optional().describe('Run the generation on Google\'s side and poll it, so a killed job can be recovered by gemini_get_result. Off by default — see gemini_video_generate'),
41
45
  timeout_ms: timeoutMsSchema,
42
46
  idempotency_key: idempotencyKeySchema,
@@ -47,28 +51,13 @@ export function registerMusicTools(server, client) {
47
51
  }, async (args, extra) => {
48
52
  assertLocalInputsAvailable(client.mediaSink, args);
49
53
  const model = args.model ?? DEFAULT_MUSIC_MODEL;
50
- if (args.audio_format === 'wav' && !isProModel(model)) {
51
- throw new McpToolError(`WAV output requires a Pro model; ${model} only produces MP3.`, {
52
- hint: 'Use model: "lyria-3-pro-preview" for WAV, or drop audio_format to get MP3.',
53
- });
54
- }
55
- let previousInteractionId = args.previous_interaction_id;
56
- if (!previousInteractionId && args.continue_last) {
57
- if (!client.session.lastMusicInteractionId) {
58
- throw new McpToolError('No previous music interaction to continue in this session.', {
59
- hint: 'Generate music first, or pass an explicit previous_interaction_id.',
60
- });
61
- }
62
- previousInteractionId = client.session.lastMusicInteractionId;
63
- }
64
54
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, 'Send local image input(s) to the Gemini Lyria API', '/v1beta/interactions', args.images);
65
55
  if (gate)
66
56
  return gate;
67
57
  const fingerprint = fingerprintRequest('gemini_music_generate', {
68
- model, prompt: args.prompt, audio_format: args.audio_format,
58
+ model, prompt: args.prompt,
69
59
  images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
70
60
  images_url: args.images_url, images_file_uris: args.images_file_uris,
71
- previous_interaction_id: previousInteractionId,
72
61
  });
73
62
  return client.session.jobs.dispatch({ toolName: 'gemini_music_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async (ctx) => {
74
63
  const { inputs, report } = await resolveImageInputs(args, client);
@@ -76,17 +65,15 @@ export function registerMusicTools(server, client) {
76
65
  input: args.prompt,
77
66
  images: inputs.length ? inputs : undefined,
78
67
  model: args.model,
79
- audioFormat: args.audio_format,
80
- previousInteractionId,
81
68
  timeoutMs: args.timeout_ms,
82
69
  background: args.background,
83
70
  // See gemini_video_generate: the id is what survives the executor.
84
71
  onInteractionStarted: ctx.reportInteraction,
85
72
  }));
86
- client.session.lastMusicInteractionId = r.id;
73
+ // The interaction id is still reported — it is the handle
74
+ // gemini_get_result recovers a killed job's output with — but it is not
75
+ // a conversation handle: Lyria has no second turn.
87
76
  const meta = { model, interaction_id: r.id };
88
- if (previousInteractionId)
89
- meta.previous_interaction_id = previousInteractionId;
90
77
  if (r.usage)
91
78
  meta.usage = r.usage;
92
79
  attachCost(meta, model, r.usage);
@@ -94,13 +81,13 @@ export function registerMusicTools(server, client) {
94
81
  meta.text = r.text;
95
82
  if (report)
96
83
  meta.image_inputs = report;
97
- meta.hint = `To continue this track, call gemini_music_generate again with previous_interaction_id: "${r.id}" (or continue_last: true).`;
84
+ meta.hint = 'Lyria is single-turn: to change this track, call gemini_music_generate again with a fuller prompt rather than a follow-up instruction.';
98
85
  const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
99
86
  const named = r.audios.map((media, i) => ({
100
87
  media,
101
88
  base: r.audios.length > 1 ? `${slug}-${String(i + 1).padStart(2, '0')}` : slug,
102
89
  }));
103
- return emitMedia(named, 'audio', { inline: args.inline, output_dir: args.output_dir, sink: client.mediaSink }, meta);
90
+ return emitMedia(named, 'audio', { inline: args.inline, output_dir: args.output_dir, sink: client.mediaSink, sidecar: { prompt: args.prompt } }, meta);
104
91
  });
105
92
  });
106
93
  }
package/dist/tools/set.js CHANGED
@@ -3,7 +3,7 @@ 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, resolveAspectRatio, ASPECT_RATIOS, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesR2KeysSchema, charactersSchema, styleSchema, resolveCharacterRefs, composePrompt, persistBundle, SET_URL_TTL_MS } from './shared.js';
6
+ import { emit, resolveAspectRatio, ASPECT_RATIOS, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesBase64Schema, imagesR2KeysSchema, charactersSchema, styleSchema, resolveCharacterRefs, composePrompt, persistBundle, SET_URL_TTL_MS } from './shared.js';
7
7
  import { fingerprintRequest } from '../jobs.js';
8
8
  import { sumUsage } from '../usage.js';
9
9
  import { attachCost } from '../pricing.js';
@@ -25,7 +25,7 @@ export function registerSetTools(server, client) {
25
25
  master_images_r2_keys: imagesR2KeysSchema('Reference images passed to the master AND to every scene call'),
26
26
  characters: charactersSchema,
27
27
  style: styleSchema,
28
- 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'),
28
+ master_images_base64: imagesBase64Schema('Reference images for the master generation', 'master_images'),
29
29
  confirm: schemaConfirm,
30
30
  ...sharedImageSchema,
31
31
  },
@@ -190,7 +190,7 @@ export function registerSetTools(server, client) {
190
190
  // A set is a batch someone comes back to: sign its links (and the
191
191
  // bundle's) for ~7 days rather than the default ~48h. The sink clamps
192
192
  // to the retention window; the disk sink ignores it entirely.
193
- return emit(named, { ...args, sink: client.mediaSink, urlTtlMs: SET_URL_TTL_MS }, meta);
193
+ return emit(named, { ...args, sink: client.mediaSink, urlTtlMs: SET_URL_TTL_MS, sidecar: { prompt: args.master_prompt } }, meta);
194
194
  });
195
195
  });
196
196
  }