@kolbo/mcp 1.79.7 → 1.80.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.
@@ -372,7 +372,7 @@ const projectIdField = z.string().optional().describe(
372
372
  // something a caller can rely on — see kolbo-api sdkSessionManager.) Threading
373
373
  // the id returned by the FIRST call is the deterministic way to group a batch.
374
374
  const sessionIdField = z.string().optional().describe(
375
- 'Existing session to add this generation to, so a related set lands in ONE session instead of a stack of single-item sessions in the Kolbo sidebar. HOW TO USE: omit it on the FIRST call of a set, read `session_id` off that call\'s result, then pass that SAME value on every follow-up call belonging to the same set (e.g. shot 2, 3, 4 of one sequence). Only group things that genuinely belong together an unrelated generation should start a fresh session by omitting this. The id must come from a session of the same kind (image tools share one session type, video tools another); `list_sessions` also returns ids. When set, `project_id` is ignored — the session\'s own project wins.'
375
+ 'Existing session to add this generation to, so a related set lands in ONE session instead of a stack of single-item sessions in the Kolbo sidebar. HOW TO USE: omit it on the FIRST call of a PLAN BUCKET (Cast, Locations, Props, or one Scene NN), read `session_id` off that result, `rename_session` to the plan name, then pass that SAME value on every follow-up in that bucket (another character, shot 2, a retake, "make it darker"). Only omit it again when the plan starts a NEW scene or NEW concept never per take or per tool call. Image tools and video tools cannot share an id. `list_sessions` also returns ids. When set, `project_id` is ignored — the session\'s own project wins.'
376
376
  );
377
377
 
378
378
  // Read-scope variant for list/get tools that can surface a SHARED project's
@@ -534,6 +534,25 @@ const _dnaChipCache = new Map();
534
534
  let _dnaChipLoaded = 0;
535
535
  const DNA_CHIP_TTL = 5 * 60 * 1000;
536
536
 
537
+ function dnaThumb(row) {
538
+ if (!row || typeof row !== 'object') return null;
539
+ const first = Array.isArray(row.images) ? row.images[0] : null;
540
+ return row.sheet_url
541
+ || row.thumbnail_url
542
+ || row.characterSheet
543
+ || (typeof first === 'string' ? first : first && first.url)
544
+ || null;
545
+ }
546
+
547
+ function rememberDna(row, fallbackId) {
548
+ if (!row || typeof row !== 'object') return null;
549
+ const id = String(row.id || row._id || fallbackId || '');
550
+ if (!id) return null;
551
+ const rec = { id, name: row.name || id, thumbnail: dnaThumb(row) };
552
+ _dnaChipCache.set(id, rec);
553
+ return rec;
554
+ }
555
+
537
556
  async function resolveVisualDnas(client, ids) {
538
557
  const list = Array.isArray(ids) ? ids.filter((id) => typeof id === 'string' && id) : [];
539
558
  if (!list.length) return [];
@@ -543,23 +562,128 @@ async function resolveVisualDnas(client, ids) {
543
562
  try {
544
563
  const res = await client.get('/v1/visual-dna?scope=mine');
545
564
  const rows = res?.visual_dnas || res?.data || [];
546
- for (const row of rows) {
547
- const id = row?.id || row?._id;
565
+ for (const row of rows) rememberDna(row);
566
+ _dnaChipLoaded = Date.now();
567
+ } catch {
568
+ // Offline / rate-limited — fall through to per-id fetch.
569
+ }
570
+ }
571
+
572
+ // scope=mine misses global / shared / teammate DNAs. The generating card
573
+ // then printed "1 Visual DNA" with no face. Fetch the missing ids directly.
574
+ const missing = list.filter((id) => !_dnaChipCache.has(id));
575
+ if (missing.length) {
576
+ await Promise.all(missing.map(async (id) => {
577
+ try {
578
+ const res = await client.get(`/v1/visual-dna/${encodeURIComponent(id)}`);
579
+ rememberDna(res && res.visual_dna ? res.visual_dna : res, id);
580
+ } catch {
581
+ // Leave the bare id — the chip still names it.
582
+ }
583
+ }));
584
+ }
585
+
586
+ return list.map((id) => _dnaChipCache.get(id) || { id, name: id, thumbnail: null });
587
+ }
588
+
589
+ const _presetCache = new Map();
590
+ let _presetLoaded = 0;
591
+ const PRESET_TTL = 10 * 60 * 1000;
592
+
593
+ async function resolvePreset(client, presetId) {
594
+ if (!presetId) return null;
595
+ const key = String(presetId);
596
+ const stale = Date.now() - _presetLoaded > PRESET_TTL;
597
+ if (!stale && _presetCache.has(key)) return _presetCache.get(key);
598
+ if (stale || _presetCache.size === 0) {
599
+ try {
600
+ const res = await client.get('/v1/presets');
601
+ for (const row of res?.presets || res?.data || []) {
602
+ const id = row?.id || row?._id || row?.identifier;
548
603
  if (!id) continue;
549
- _dnaChipCache.set(String(id), {
604
+ _presetCache.set(String(id), {
550
605
  id: String(id),
551
606
  name: row.name || String(id),
552
- // Same hero-image rule as the app: reference sheet, then thumbnail.
553
- thumbnail: row.sheet_url || row.thumbnail_url || (Array.isArray(row.images) ? row.images[0] : null) || null,
607
+ thumbnail: row.thumbnail_url || row.thumbnail || null,
554
608
  });
555
609
  }
556
- _dnaChipLoaded = Date.now();
610
+ _presetLoaded = Date.now();
557
611
  } catch {
558
- // Offline / rate-limited fall through to bare ids.
612
+ // Offline the chip keeps the word "preset".
559
613
  }
560
614
  }
615
+ return _presetCache.get(key) || null;
616
+ }
561
617
 
562
- return list.map((id) => _dnaChipCache.get(id) || { id, name: id, thumbnail: null });
618
+ async function decorateSettings(client, settings) {
619
+ const s = { ...(settings || {}) };
620
+ if (!s.preset_id) return s;
621
+ const preset = await resolvePreset(client, s.preset_id);
622
+ if (!preset) return s;
623
+ return {
624
+ ...s,
625
+ preset_name: preset.name,
626
+ ...(preset.thumbnail ? { preset_thumbnail: preset.thumbnail } : {}),
627
+ };
628
+ }
629
+
630
+ const _mbChipCache = new Map();
631
+ let _mbChipLoaded = 0;
632
+
633
+ function rememberMoodboard(row, fallbackId) {
634
+ if (!row || typeof row !== 'object') return null;
635
+ const id = String(row.id || row._id || fallbackId || '');
636
+ if (!id) return null;
637
+ const rec = {
638
+ id,
639
+ name: row.name || id,
640
+ thumbnail: row.thumbnail_url || row.thumbnail || row.cover_url || (Array.isArray(row.images) ? row.images[0] : null) || null,
641
+ };
642
+ _mbChipCache.set(id, rec);
643
+ return rec;
644
+ }
645
+
646
+ async function resolveMoodboards(client, ids) {
647
+ const list = Array.isArray(ids) ? ids.filter((id) => typeof id === 'string' && id) : [];
648
+ if (!list.length) return [];
649
+ const stale = Date.now() - _mbChipLoaded > DNA_CHIP_TTL;
650
+ if (stale || list.some((id) => !_mbChipCache.has(id))) {
651
+ try {
652
+ const res = await client.get('/v1/moodboards');
653
+ for (const row of res?.moodboards || res?.data || []) rememberMoodboard(row);
654
+ _mbChipLoaded = Date.now();
655
+ } catch { /* fall through to per-id */ }
656
+ }
657
+ const missing = list.filter((id) => !_mbChipCache.has(id));
658
+ if (missing.length) {
659
+ await Promise.all(missing.map(async (id) => {
660
+ try {
661
+ const res = await client.get(`/v1/moodboards/${encodeURIComponent(id)}`);
662
+ rememberMoodboard(res && res.moodboard ? res.moodboard : res, id);
663
+ } catch { /* bare id */ }
664
+ }));
665
+ }
666
+ return list.map((id) => _mbChipCache.get(id) || { id, name: id, thumbnail: null });
667
+ }
668
+
669
+ function mediaRefs(p) {
670
+ const images = Array.isArray(p.reference_images)
671
+ ? p.reference_images.filter(Boolean)
672
+ : (p.reference_image ? [p.reference_image] : []);
673
+ return {
674
+ reference_images: images,
675
+ reference_image: p.reference_image || images[0],
676
+ ...(Array.isArray(p.reference_videos) && p.reference_videos.length
677
+ ? { reference_videos: p.reference_videos.filter(Boolean) } : {}),
678
+ ...(Array.isArray(p.reference_audio) && p.reference_audio.length
679
+ ? { reference_audio: p.reference_audio.filter(Boolean) } : {}),
680
+ };
681
+ }
682
+
683
+ function moodboardIds(settings) {
684
+ const s = settings || {};
685
+ if (Array.isArray(s.moodboard_ids) && s.moodboard_ids.length) return s.moodboard_ids;
686
+ return s.moodboard_id ? [s.moodboard_id] : [];
563
687
  }
564
688
 
565
689
  /**
@@ -577,6 +701,7 @@ async function resolveVisualDnas(client, ids) {
577
701
  async function uiGenerating(p) {
578
702
  // No ETAs anywhere — just a spinner until the poll flips to completed.
579
703
  const chip = await modelChipFields(p.client, p.model);
704
+ const settings = await decorateSettings(p.client, p.settings || {});
580
705
  const structured = {
581
706
  phase: 'generating',
582
707
  widget: 'generation',
@@ -592,14 +717,12 @@ async function uiGenerating(p) {
592
717
  ...(p.voice ? { voice_name: p.voice.name, voice_thumbnail: p.voice.thumbnail } : {}),
593
718
  prompt: p.prompt,
594
719
  count: p.count || 1,
595
- settings: p.settings || {},
596
- visual_dnas: await resolveVisualDnas(p.client, (p.settings || {}).visual_dna_ids),
720
+ settings,
721
+ visual_dnas: await resolveVisualDnas(p.client, settings.visual_dna_ids),
722
+ moodboards: await resolveMoodboards(p.client, moodboardIds(settings)),
597
723
  // `reference_image` is retained for older widget builds. New widgets render
598
724
  // every browser-loadable image supplied to the generation.
599
- reference_images: Array.isArray(p.reference_images)
600
- ? p.reference_images.filter(Boolean)
601
- : (p.reference_image ? [p.reference_image] : []),
602
- reference_image: p.reference_image || p.reference_images?.find(Boolean),
725
+ ...mediaRefs(p),
603
726
  open_url: buildOpenUrl(p.tool, p.gen),
604
727
  };
605
728
  // Batch mode (prompts[] fan-out): ONE widget tracks every id in the set.
@@ -638,8 +761,22 @@ async function uiGenerating(p) {
638
761
  *
639
762
  * The TEXT is unchanged, so text-only hosts see precisely what they saw before.
640
763
  */
764
+ function preferOwnedUrls(urls) {
765
+ const list = Array.isArray(urls) ? urls.filter((item) => typeof item === 'string' && item) : [];
766
+ const ours = list.filter((item) => {
767
+ try {
768
+ const host = new URL(item).hostname;
769
+ return /(?:^|\.)kolbo\.ai$/.test(host) || /digitaloceanspaces\.com$/.test(host);
770
+ } catch {
771
+ return false;
772
+ }
773
+ });
774
+ return ours.length ? ours : (list.length ? list : urls);
775
+ }
776
+
641
777
  async function uiCompleted(p, textPayload, extraContent) {
642
778
  const chip = await modelChipFields(p.client, p.model);
779
+ const settings = p.settings ? await decorateSettings(p.client, p.settings) : undefined;
643
780
  const structured = {
644
781
  phase: 'completed',
645
782
  widget: 'generation',
@@ -652,13 +789,11 @@ async function uiCompleted(p, textPayload, extraContent) {
652
789
  // an incoming status payload over its own state, so an empty-but-present
653
790
  // `settings` wiped the resolution / aspect / DNA chips off the finished
654
791
  // card. Every reader already does `sc.settings || {}`.
655
- ...(p.settings ? { settings: p.settings } : {}),
656
- visual_dnas: await resolveVisualDnas(p.client, (p.settings || {}).visual_dna_ids),
657
- reference_images: Array.isArray(p.reference_images)
658
- ? p.reference_images.filter(Boolean)
659
- : (p.reference_image ? [p.reference_image] : []),
660
- reference_image: p.reference_image || p.reference_images?.find(Boolean),
661
- urls: p.urls,
792
+ ...(settings ? { settings } : {}),
793
+ visual_dnas: await resolveVisualDnas(p.client, (settings || {}).visual_dna_ids),
794
+ moodboards: await resolveMoodboards(p.client, moodboardIds(settings)),
795
+ ...mediaRefs(p),
796
+ urls: preferOwnedUrls(p.urls),
662
797
  thumbnail_url: p.thumbnail_url,
663
798
  title: p.title,
664
799
  duration: p.duration,
@@ -7,7 +7,8 @@ const { z } = require('zod');
7
7
  const FormData = require('form-data');
8
8
  const { pollUntilDone, waitWindowMs } = require('../polling');
9
9
  const { resolveToBuffer, pollOrTimedOut, creditFields, projectIdField, sessionIdField, inlineImageBlocks, buildOpenUrl, uiGenerating, uiCompleted, appsEnabled } = require('./_shared');
10
- const { UI, uiResult, canonicalModelId, modelInfo, voiceInfo } = require('../apps');
10
+ const { ownedUrl } = require('./owned-url');
11
+ const { UI, uiResult, canonicalModelId, modelInfo, voiceInfo, resolveCatalogAspectRatio } = require('../apps');
11
12
 
12
13
  // ─── Cinematic Dimensions schema (shared by generate_image + generate_image_edit) ───
13
14
  // Kolbo's "Cinema mode": eight independent photographic dimensions, each an OPTIONAL
@@ -114,11 +115,18 @@ async function pollBatch(client, batch, { interval, timeout }, toolName) {
114
115
  function mediaKind(url) {
115
116
  const u = String(url || '').split('?')[0].toLowerCase();
116
117
  if (/\.(mp4|mov|webm|mkv)$/.test(u)) return 'video';
118
+ if (/video-elements-results|generated-videos|\/videos?\//i.test(u)) return 'video';
117
119
  if (/\.(mp3|wav|m4a|aac|ogg|flac)$/.test(u)) return 'audio';
118
120
  if (/\.(glb|gltf|fbx|obj|usdz)$/.test(u)) return '3d';
119
121
  return 'image';
120
122
  }
121
123
 
124
+ function preferOwned(urls) {
125
+ const list = (urls || []).filter((item) => typeof item === 'string' && item);
126
+ const ours = list.filter(ownedUrl);
127
+ return ours.length ? ours : list;
128
+ }
129
+
122
130
  const isUrlSource = (source) => typeof source === 'string' && /^https?:\/\//i.test(source);
123
131
 
124
132
  // Which multipart kind a LOCAL file should upload as. mediaKind answers for the
@@ -155,6 +163,12 @@ const refSettings = (a = {}) => ({
155
163
  cinematic: a.cinematic ? true : undefined,
156
164
  });
157
165
 
166
+ const aspectRatioDescribe = (fallback) =>
167
+ 'Aspect ratio (e.g. "16:9", "9:16", "1:1"). MCP always snaps unsupported values '
168
+ + 'to the closest ratio this model accepts — the card and the request both use '
169
+ + 'the snapped value. Prefer list_models `supported_aspect_ratios`.'
170
+ + (fallback ? ` Default: "${fallback}".` : '');
171
+
158
172
  const imageSettings = (a = {}) => ({
159
173
  resolution: a.resolution,
160
174
  aspect_ratio: a.aspect_ratio,
@@ -206,12 +220,12 @@ function registerGenerateTools(server, client, options = {}) {
206
220
  // ─── generate_image ────────────────────────────────────────
207
221
  server.tool(
208
222
  'generate_image',
209
- 'Generate image(s) from a text prompt using Kolbo AI. Supports Visual DNA profiles (for character/style/product consistency), moodboards (for style direction), Kolbo image presets, reference images (for composition guidance), batch generation (num_images for variations of ONE prompt, `prompts` for SEVERAL different prompts in one combined widget), and web-search grounding. PRESET CONTRACT: when the user asks to use a preset, a named preset, or "one of my/Kolbo image presets", call list_presets type="image" first, resolve the requested or best-matching preset, and pass its exact id as `preset_id`; never silently generate without it. When the user wants multiple distinct images, pass all their prompts in `prompts` in ONE call — never a series of separate generate_image calls. For EDITING an existing image, use generate_image_edit instead. For a coordinated multi-scene set planned by AI from a single brief (storyboard, ad campaign), use generate_creative_director. Returns the final image URL(s) when complete.',
223
+ 'Generate image(s) from a text prompt using Kolbo AI. Supports Visual DNA profiles (for character/style/product consistency), moodboards (for style direction), Kolbo image presets, reference images (for composition guidance), batch generation (num_images for variations of ONE prompt, `prompts` for SEVERAL different prompts in one combined widget), and web-search grounding. PRESET CONTRACT: resolve with list_presets type="image" AND search="<name>" (headless, bible, character sheet, or a user preset), then pass the exact id as `preset_id`. Custom instructions live on the preset — prefer this over generate_character_sheet. Never list the whole catalog. When the user wants multiple distinct images, pass all their prompts in `prompts` in ONE call — never a series of separate generate_image calls. For EDITING an existing image, use generate_image_edit instead. For a coordinated multi-scene set planned by AI from a single brief (storyboard, ad campaign), use generate_creative_director. Returns the final image URL(s) when complete.',
210
224
  {
211
225
  prompt: z.string().optional().describe('Text description of the image to generate. Required unless `prompts` is provided.'),
212
226
  prompts: promptsField('images'),
213
227
  model: z.string().optional().describe('Model identifier — REQUIRED in practice: pick a specific model, do NOT omit (omitting = Smart Select auto-pick, which we avoid). Strong current defaults: "nano-banana-2" (versatile, text rendering, multilingual) or "gpt-image-2" (photoreal, infographics). Call list_models type="text_to_img" to see all options and pick per the user\'s intent.'),
214
- aspect_ratio: z.string().optional().describe('Aspect ratio (e.g., "1:1", "16:9", "9:16"). Must be a value present in the model\'s `supported_aspect_ratios` from list_models — pass an unsupported value and the API rejects. Default: "1:1"'),
228
+ aspect_ratio: z.string().optional().describe(aspectRatioDescribe('1:1')),
215
229
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt for better results. Default: false — only pass true if the user explicitly asks to enhance/improve the prompt.'),
216
230
  num_images: z.number().optional().describe('Number of images to generate in one call. Default: 1. Note: some models (Midjourney etc.) have a fixed `images_per_request` and ignore this — check list_models.'),
217
231
  reference_images: z.array(z.string()).optional().describe('STYLE/COMPOSITION inspiration only — does NOT embed reference pixels. Array of image URLs used to guide the look-and-feel of a brand-new generation. The model interprets the references and regenerates approximations conditioned on them. It will NOT copy pixels from these images into the output. **Cap: pass at most `max_reference_images` URLs from list_models for the chosen model — exceeding it is a deterministic 400.** To embed a specific logo, icon, watermark, or asset pixel-accurately, use generate_image_edit with the asset in source_images. To EDIT an existing image, also use generate_image_edit.'),
@@ -229,6 +243,7 @@ function registerGenerateTools(server, client, options = {}) {
229
243
  async ({ prompt, prompts, model, aspect_ratio, enhance_prompt = false, num_images, reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id, session_id }) => {
230
244
  if (!prompt && !(prompts && prompts.length)) throw new Error('Provide prompt or prompts');
231
245
  model = await canonicalModelId(client, model, 'text_to_img'); // lenient id resolution ("z-image" → "z-image/turbo")
246
+ aspect_ratio = await resolveCatalogAspectRatio(client, model, aspect_ratio, 'text_to_img');
232
247
  const shared = {
233
248
  model, aspect_ratio, enhance_prompt,
234
249
  reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id, session_id
@@ -291,7 +306,7 @@ function registerGenerateTools(server, client, options = {}) {
291
306
  model: z.string().optional().describe('Model identifier — REQUIRED in practice: pick a specific model, do NOT omit (omitting = Smart Select auto-pick, which we avoid). Many text-to-image ids double as editors: the server auto-routes a base id to its editing variant when source_images is present (e.g. "gpt-image-2" → gpt-image-2/edit, "nano-banana-2" → nano-banana-2-image-editing) — passing the bare id is fine, no need to hunt for the "/edit" suffix yourself. BUT this only works for models that actually have a registered edit variant. For prompt-driven photoreal photo edits (object removal, keep-this-person/remove-the-rest, crowd cleanup, inpainting) the ONLY auto-pick defaults are "nano-banana-2" or "gpt-image-2" (use GPT Image 2 when the image needs readable text). Do NOT auto-pick Flux 2 / flux-2/edit / Flux Klein — those are generate-from-scratch / style models; use them only if the user names Flux. If unsure, confirm the model appears in `list_models type="image_editing"` and choose by the strengths summary — Flux edit variants are named-only.'),
292
307
  source_images: z.array(z.string()).describe('PIXEL-ACCURATE compositing. Array of source image URLs whose pixel content is composited into the output. **Cap: pass at most `max_reference_images` URLs from list_models for the chosen model — exceeding it is a deterministic 400.** Three modes the model auto-detects from input shape: (1) Single image → edit/transform that image. (2) Multiple images, one base + others → composite the others into the base. (3) Multiple images with no clear base → generate a new scene that pixel-accurately embeds the supplied images at positions described in the prompt. Mode 3 is the canonical pattern for thumbnails / branded compositions where exact-pixel logo + face fidelity matter. Refer to source images in the prompt by ordinal position ("FIRST source image", "SECOND source image") or use @image1/@image2 tags. Add "composite AS-IS, do not redraw or restyle" to lock pixels.'),
293
308
  reference_images: z.array(z.string()).optional().describe('STYLE/COMPOSITION inspiration, alongside `source_images` on the same call — does NOT embed reference pixels. Use when the edit should follow a look sampled from other images ("re-light this shot like these references"). The pixels that must survive the edit go in `source_images`; these only steer the look. **Cap: `source_images` + `reference_images` together must not exceed `max_reference_images` from list_models for the chosen model.**'),
294
- aspect_ratio: z.string().optional().describe('Output aspect ratio (e.g., "1:1", "16:9", "9:16"). Must be in the chosen model\'s `supported_aspect_ratios` from list_models. Default: "1:1"'),
309
+ aspect_ratio: z.string().optional().describe(aspectRatioDescribe('1:1')),
295
310
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt for better results. Default: false — only pass true if the user explicitly asks to enhance/improve the prompt.'),
296
311
  num_images: z.number().optional().describe('Number of output images. Default: 1'),
297
312
  visual_dna_ids: z.array(z.string()).optional().describe('Visual DNA profile IDs for character / style / product consistency. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model.** How DNA works: the server fetches the DNA\'s reference images AND always injects its `description` field into the prompt as plaintext (by design — independent of enhance_prompt). For pixel-accurate face anchoring of a specific person on this tool, the PREFERRED pattern is to pass the face photo directly via source_images and OMIT visual_dna_ids — that way the face pixels anchor the output and no description text competes. Do NOT pass visual_dna_ids if source_images already contains the same person\'s face (face averaging). visual_dna_ids is best here for style / product DNAs.'),
@@ -308,6 +323,7 @@ function registerGenerateTools(server, client, options = {}) {
308
323
  async ({ prompt, prompts, model, source_images, reference_images, aspect_ratio, enhance_prompt = false, num_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id, session_id }) => {
309
324
  if (!prompt && !(prompts && prompts.length)) throw new Error('Provide prompt or prompts');
310
325
  model = await canonicalModelId(client, model, 'image_editing'); // lenient id resolution ("z-image" → "z-image/turbo")
326
+ aspect_ratio = await resolveCatalogAspectRatio(client, model, aspect_ratio, 'image_editing');
311
327
  const shared = {
312
328
  model, source_images, reference_images, aspect_ratio, enhance_prompt,
313
329
  visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id, session_id
@@ -326,7 +342,7 @@ function registerGenerateTools(server, client, options = {}) {
326
342
  generation_ids: batch.ids, prompts: batch.ok.map((o) => o.prompt),
327
343
  failed_submissions: batch.failed,
328
344
  status_args: { generation_ids: batch.ids, wait: true },
329
- reference_images: source_images
345
+ reference_images: [...(source_images || []), ...(reference_images || [])]
330
346
  });
331
347
  return pollBatch(client, batch, { interval: (batch.ok[0].gen.poll_interval_hint || 3) * 1000, timeout: 150000 }, 'generate_image_edit');
332
348
  }
@@ -337,7 +353,7 @@ function registerGenerateTools(server, client, options = {}) {
337
353
  tool: 'generate_image_edit', kind: 'image', gen, client, model, prompt,
338
354
  count: num_images,
339
355
  settings,
340
- reference_images: source_images
356
+ reference_images: [...(source_images || []), ...(reference_images || [])]
341
357
  });
342
358
 
343
359
  // Multi-source compositing or DNA-anchored edits routinely exceed 120s
@@ -355,7 +371,7 @@ function registerGenerateTools(server, client, options = {}) {
355
371
  return uiCompleted({
356
372
  tool: 'generate_image_edit', kind: 'image', gen, client, model, prompt,
357
373
  count: num_images, settings: imageSettings(shared),
358
- reference_images: source_images,
374
+ reference_images: [...(source_images || []), ...(reference_images || [])],
359
375
  urls: result.result.urls,
360
376
  credits_used: creditFields(result).credits_used,
361
377
  }, JSON.stringify({
@@ -377,7 +393,7 @@ function registerGenerateTools(server, client, options = {}) {
377
393
  prompt: z.string().describe('Creative brief or concept describing the full set of scenes to generate'),
378
394
  scene_count: z.number().optional().describe('Number of scenes/images to generate, 1–8. Default: 4. Use this — NOT num_images — to control how many outputs are created.'),
379
395
  model: z.string().optional().describe('Model identifier applied to every scene. Pick a SPECIFIC model — do NOT omit (omitting = Smart Select auto-pick, which we avoid); call list_models for this type and choose the model that best fits the user\'s intent.'),
380
- aspect_ratio: z.string().optional().describe('Aspect ratio applied to every scene (e.g., "1:1", "16:9", "9:16"). Must be in the chosen model\'s `supported_aspect_ratios` from list_models. Default: "1:1"'),
396
+ aspect_ratio: z.string().optional().describe(aspectRatioDescribe('1:1')),
381
397
  workflow_type: z.string().optional().describe('"image" (default) or "video"'),
382
398
  duration: z.number().optional().describe('Duration in seconds per scene (video mode only). Must be a value in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration`. E.g., 5 or 10.'),
383
399
  enhance_prompt: z.boolean().optional().describe('Enhance prompts per scene. Default: false — only pass true if the user explicitly asks to enhance/improve the prompts.'),
@@ -390,6 +406,7 @@ function registerGenerateTools(server, client, options = {}) {
390
406
  },
391
407
  async ({ prompt, scene_count, model, aspect_ratio, workflow_type, duration, enhance_prompt = false, reference_images, visual_dna_ids, moodboard_id, moodboard_ids, resolution, project_id }) => {
392
408
  model = await canonicalModelId(client, model, workflow_type === 'video' ? 'text_to_video' : 'text_to_img'); // lenient id resolution ("z-image" → "z-image/turbo")
409
+ aspect_ratio = await resolveCatalogAspectRatio(client, model, aspect_ratio, workflow_type === 'video' ? 'text_to_video' : 'text_to_img');
393
410
  const gen = await client.post('/v1/generate/creative-director', {
394
411
  prompt, scene_count, model, aspect_ratio, workflow_type, duration,
395
412
  enhance_prompt, reference_images, visual_dna_ids, moodboard_id, moodboard_ids, resolution, project_id
@@ -546,7 +563,7 @@ function registerGenerateTools(server, client, options = {}) {
546
563
  prompt: z.string().optional().describe('Text description of the video to generate. Required unless `prompts` is provided.'),
547
564
  prompts: promptsField('videos'),
548
565
  model: z.string().optional().describe('Model identifier — pick a SPECIFIC model, do NOT omit (omitting = Smart Select auto-pick, which we avoid). If the user already named a model/family (this turn or earlier), pass that name — do not substitute a cheaper default. Only when no model was named: "seedance-2" (versatile) or "veo3" (cinematic + native audio) are reasonable auto-picks; Kling is strongest for motion (list_models type="text_to_video" for exact ids). Call list_models for supported_durations / supported_aspect_ratios.'),
549
- aspect_ratio: z.string().optional().describe('Aspect ratio (e.g., "16:9", "9:16", "1:1"). Must be in the chosen model\'s `supported_aspect_ratios` from list_models. Default: "16:9"'),
566
+ aspect_ratio: z.string().optional().describe(aspectRatioDescribe('16:9')),
550
567
  duration: z.number().optional().describe('Duration in seconds. Must be a value in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration` (whichever the model exposes). Default: 5'),
551
568
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: false — only pass true if the user explicitly asks to enhance/improve the prompt.'),
552
569
  reference_images: z.array(z.string()).optional().describe('Array of image URLs used as visual references (style / composition / subject). **Cap: pass at most `max_reference_images` URLs from list_models for the chosen model — exceeding it is a deterministic 400.**'),
@@ -561,6 +578,7 @@ function registerGenerateTools(server, client, options = {}) {
561
578
  async ({ prompt, prompts, model, aspect_ratio, duration, enhance_prompt = false, reference_images, resolution, preset_id, visual_dna_ids, sound_enabled, skip_color_palette, project_id, session_id }) => {
562
579
  if (!prompt && !(prompts && prompts.length)) throw new Error('Provide prompt or prompts');
563
580
  model = await canonicalModelId(client, model, 'text_to_video'); // lenient id resolution ("z-image" → "z-image/turbo")
581
+ aspect_ratio = await resolveCatalogAspectRatio(client, model, aspect_ratio, 'text_to_video');
564
582
  const shared = {
565
583
  model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, preset_id, visual_dna_ids, sound_enabled, skip_color_palette, project_id, session_id
566
584
  };
@@ -570,7 +588,7 @@ function registerGenerateTools(server, client, options = {}) {
570
588
  const batch = await submitBatch(prompts, (p) => client.post('/v1/generate/video', { ...shared, prompt: p }));
571
589
  if (ui()) return uiGenerating({
572
590
  tool: 'generate_video', kind: 'video', gen: batch.ok[0].gen, client, model,
573
- count: batch.ids.length, settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, preset_id }),
591
+ count: batch.ids.length, settings: videoSettings(shared),
574
592
  generation_ids: batch.ids, prompts: batch.ok.map((o) => o.prompt),
575
593
  failed_submissions: batch.failed,
576
594
  status_args: { generation_ids: batch.ids, wait: true },
@@ -583,7 +601,7 @@ function registerGenerateTools(server, client, options = {}) {
583
601
 
584
602
  if (ui()) return uiGenerating({
585
603
  tool: 'generate_video', kind: 'video', gen, client, model, prompt,
586
- settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, preset_id }),
604
+ settings: videoSettings(shared),
587
605
  reference_images
588
606
  });
589
607
 
@@ -599,7 +617,7 @@ function registerGenerateTools(server, client, options = {}) {
599
617
 
600
618
  return uiCompleted({
601
619
  tool: 'generate_video', kind: 'video', gen, client, model, prompt,
602
- settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, preset_id }),
620
+ settings: videoSettings(shared),
603
621
  reference_images,
604
622
  urls: result.result.urls,
605
623
  thumbnail_url: result.result.thumbnail_url,
@@ -632,7 +650,7 @@ function registerGenerateTools(server, client, options = {}) {
632
650
  `BATCH MODE — several DIFFERENT stills (2–${MAX_BATCH_PROMPTS}) animated concurrently in ONE call and rendered together in ONE combined widget. Unlike the \`prompts\` array on generate_image / generate_video, each entry pairs its OWN \`image_url\` with its OWN motion \`prompt\` — the image is what varies, and that is the point. **Hard cap: ${MAX_BATCH_PROMPTS} items per call — more than that is REJECTED with an error (never silently truncated), so split a longer sequence across several calls of at most ${MAX_BATCH_PROMPTS}.** Whenever the user wants several stills animated (a shot sequence, a storyboard, an animatic), ALWAYS pass them all here instead of making several separate calls — separate calls clutter the chat with stacked widgets. All items share the same model / duration / resolution / aspect_ratio / sound_enabled / project_id / session_id. When set, \`image_url\` and \`prompt\` are ignored.`
633
651
  ),
634
652
  model: z.string().optional().describe('Model identifier — pick a SPECIFIC model, do NOT omit (omitting = Smart Select auto-pick, which we avoid). If the user already named a model/family (this turn or earlier), pass that name — a text-to-video id remaps to the family\'s image-to-video sibling. Do not substitute a cheaper default (named Grok Imagine → not Seedance). Only when no model was named: "seedance-2" or "veo3" are reasonable auto-picks; Kling is strongest for motion (list_models type="img_to_video" for exact ids).'),
635
- aspect_ratio: z.string().optional().describe('Output aspect ratio (e.g., "16:9", "9:16", "1:1"). Must be in the chosen model\'s `supported_aspect_ratios` from list_models. Default: "16:9"'),
653
+ aspect_ratio: z.string().optional().describe(aspectRatioDescribe('16:9')),
636
654
  duration: z.number().optional().describe('Duration in seconds. Must be in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration`. Default: 5'),
637
655
  enhance_prompt: z.boolean().optional().describe('Enhance the motion prompt. Default: false — only pass true if the user explicitly asks to enhance/improve the prompt.'),
638
656
  visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to maintain consistency with prior characters / styles. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model; if `supports_visual_dna: false` the model ignores DNA entirely.**'),
@@ -645,6 +663,7 @@ function registerGenerateTools(server, client, options = {}) {
645
663
  async ({ image_url, prompt, items, model, aspect_ratio, duration, enhance_prompt = false, visual_dna_ids, resolution, sound_enabled, skip_color_palette, project_id, session_id }) => {
646
664
  if (!(items && items.length) && !(image_url && prompt)) throw new Error('Provide image_url + prompt, or items');
647
665
  model = await canonicalModelId(client, model, 'img_to_video'); // lenient id resolution ("z-image" → "z-image/turbo")
666
+ aspect_ratio = await resolveCatalogAspectRatio(client, model, aspect_ratio, 'img_to_video');
648
667
  const shared = {
649
668
  model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution, sound_enabled, skip_color_palette, project_id, session_id
650
669
  };
@@ -741,7 +760,7 @@ function registerGenerateTools(server, client, options = {}) {
741
760
 
742
761
  if (ui()) return uiGenerating({
743
762
  tool: 'generate_music', kind: 'audio', gen, client, model: model || 'Suno', prompt,
744
- settings: { mode: instrumental ? 'instrumental' : (style || undefined) },
763
+ settings: { mode: instrumental ? 'instrumental' : (style || undefined), preset_id },
745
764
  });
746
765
 
747
766
  const poll = await pollOrTimedOut(client, gen.generation_id, {
@@ -753,7 +772,7 @@ function registerGenerateTools(server, client, options = {}) {
753
772
 
754
773
  return uiCompleted({
755
774
  tool: 'generate_music', kind: 'audio', gen, client, model: model || 'Suno', prompt,
756
- settings: { mode: instrumental ? 'instrumental' : (style || undefined) },
775
+ settings: { mode: instrumental ? 'instrumental' : (style || undefined), preset_id },
757
776
  urls: result.result.urls,
758
777
  title: result.result.title,
759
778
  duration: result.result.duration,
@@ -1060,7 +1079,7 @@ function registerGenerateTools(server, client, options = {}) {
1060
1079
  // "No output received / Failed" over a generation that had completed
1061
1080
  // and billed. Every card rendered before v1.74 keeps that old iframe JS
1062
1081
  // forever, so the flat fields have to live in structuredContent too.
1063
- const urls = Array.isArray(res.urls) ? res.urls : [];
1082
+ const urls = preferOwned(Array.isArray(res.urls) ? res.urls : []);
1064
1083
  const done = single.state === 'completed' && urls.length > 0;
1065
1084
  return uiCompleted({
1066
1085
  tool: 'get_generation_status', kind: done ? mediaKind(urls[0]) : 'status', client,
@@ -1195,7 +1214,7 @@ function registerGenerateTools(server, client, options = {}) {
1195
1214
  audio_url: z.string().optional().describe('A single reference audio track — legacy form of reference_audio_urls. Accepts a public URL (forwarded as-is; if the API rejects an external URL as untrusted, it is auto-rehosted into the media library and retried once) OR an absolute local path, which is uploaded for you. **Audio constraints: `elements_max_audio` from list_models gates whether audio is accepted at all; audio duration must fall within `min_audio_duration`-`max_audio_duration`; format must be in `supported_audio_formats` (if specified).**'),
1196
1215
  files: z.array(z.string()).optional().describe('Untyped catch-all for mixed media — images, videos AND audio, each a URL or an absolute local path. The kind is detected from the file extension and the item is routed to the matching reference list, so a local .mp4 is sent as a video and a local .mp3 as audio. Prefer the typed lists (reference_images / reference_videos / reference_audio_urls) when you already know the kind; they accept local paths too. URLs given here are forwarded as URLs, never re-uploaded. **Caps still apply per kind: `elements_max_images` / `elements_max_videos` / `elements_max_audio` from list_models. Local uploads are capped at 200MB each.**'),
1197
1216
  duration: z.number().optional().describe('Output duration in seconds. Must be in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration`. Default: 5'),
1198
- aspect_ratio: z.string().optional().describe('Aspect ratio (e.g., "16:9", "9:16", "1:1"). Must be in `supported_aspect_ratios` from list_models. Default: "16:9"'),
1217
+ aspect_ratio: z.string().optional().describe(aspectRatioDescribe('16:9')),
1199
1218
  motion: z.string().optional().describe('Motion style / intensity hint (optional)'),
1200
1219
  preset_id: z.string().optional().describe('Preset ID from list_presets type="video" (optional)'),
1201
1220
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: false — only pass true if the user explicitly asks to enhance/improve the prompt.'),
@@ -1214,6 +1233,7 @@ function registerGenerateTools(server, client, options = {}) {
1214
1233
  },
1215
1234
  async ({ prompt, model, reference_images, reference_videos, reference_audio_urls, audio_url, files, duration, aspect_ratio, motion, preset_id, enhance_prompt = false, visual_dna_ids, resolution, sound_enabled, keyframes, multi_shots, multi_shot_count, session_name, project_id, session_id }) => {
1216
1235
  model = await canonicalModelId(client, model, 'elements'); // lenient id resolution ("z-image" → "z-image/turbo")
1236
+ aspect_ratio = await resolveCatalogAspectRatio(client, model, aspect_ratio, 'elements');
1217
1237
  if (!prompt) throw new Error('prompt is required');
1218
1238
 
1219
1239
  // Elements is the one tool that takes all three modalities, and either a
@@ -1377,7 +1397,7 @@ function registerGenerateTools(server, client, options = {}) {
1377
1397
  prompt: z.string().optional().describe('Optional description of the desired motion between the two frames (e.g. "smooth camera dolly in")'),
1378
1398
  model: z.string().optional().describe('Model identifier. Use list_models type="firstlastgenerations" to see options. Pick a SPECIFIC model — do NOT omit (omitting = Smart Select auto-pick, which we avoid); call list_models for this type and choose the model that best fits the user\'s intent.'),
1379
1399
  duration: z.number().optional().describe('Duration in seconds. Must be in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration`. Default: 5'),
1380
- aspect_ratio: z.string().optional().describe('Aspect ratio (auto-detected from first frame if not provided). Must be in `supported_aspect_ratios` from list_models when set. Default: "16:9"'),
1400
+ aspect_ratio: z.string().optional().describe(aspectRatioDescribe('16:9') + ' Auto-detected from the first frame if omitted.'),
1381
1401
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: false — only pass true if the user explicitly asks to enhance/improve the prompt.'),
1382
1402
  visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model; if `supports_visual_dna: false`, DNA is silently ignored.**'),
1383
1403
  resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Model-dependent — call list_models and read supported_resolutions.'),
@@ -1387,6 +1407,7 @@ function registerGenerateTools(server, client, options = {}) {
1387
1407
  },
1388
1408
  async ({ first_frame_url, last_frame_url, first_frame, last_frame, prompt, model, duration, aspect_ratio, enhance_prompt = false, visual_dna_ids, resolution, sound_enabled, project_id, session_id }) => {
1389
1409
  model = await canonicalModelId(client, model, 'firstlastgenerations'); // lenient id resolution ("z-image" → "z-image/turbo")
1410
+ aspect_ratio = await resolveCatalogAspectRatio(client, model, aspect_ratio, 'firstlastgenerations');
1390
1411
  // One frame per position, whichever arg carried it. The two arg pairs were
1391
1412
  // treated as two exclusive MODES, so a URL handed to first_frame/last_frame
1392
1413
  // (which their own descriptions invite) took the multipart path and got
@@ -1586,7 +1607,7 @@ function registerGenerateTools(server, client, options = {}) {
1586
1607
  source_video: z.string().describe('URL or absolute local path to the primary source video to restyle. **Source duration must fall within `min_video_duration`-`max_video_duration` from list_models for the chosen model** — videos outside that range are rejected (or silently truncated by some upstream providers). For models that use reference_videos as their primary input (e.g. WAN 2.6 reference-to-video), pass the first reference video here and also include it in reference_videos.'),
1587
1608
  prompt: z.string().optional().describe('Text description of the desired restyle / transformation. Required by most video-to-video models; omit for prompt-less models (VEED Subtitles, Act Two, Wan Animate, Kling Motion Control).'),
1588
1609
  model: z.string().optional().describe('Model identifier. Use list_models type="video_to_video" to see options and check max_images / max_videos / max_elements / max_video_duration per model. Pick a SPECIFIC model — do NOT omit (omitting = Smart Select auto-pick, which we avoid); call list_models for this type and choose the model that best fits the user\'s intent.'),
1589
- aspect_ratio: z.string().optional().describe('Output aspect ratio. Must be in `supported_aspect_ratios` from list_models when set. Default: matches source'),
1610
+ aspect_ratio: z.string().optional().describe(aspectRatioDescribe() + ' Default: matches source.'),
1590
1611
  duration: z.number().optional().describe('Output duration in seconds. Must be in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration`. Default: matches source'),
1591
1612
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: false — only pass true if the user explicitly asks to enhance/improve the prompt.'),
1592
1613
  visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply for character/style consistency. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model; if `supports_visual_dna: false`, DNA is silently ignored.**'),
@@ -1626,6 +1647,7 @@ function registerGenerateTools(server, client, options = {}) {
1626
1647
  },
1627
1648
  async ({ source_video, prompt, model, aspect_ratio, duration, enhance_prompt = false, visual_dna_ids, resolution, sound_enabled, reference_images, reference_videos, elements, preset, source_language, translation_language, srt_content, srt_file_url, vocabulary, customization, enhancement_model, target_fps, slowdown_factor, output_format, project_id, session_id }) => {
1628
1649
  model = await canonicalModelId(client, model, 'video_to_video'); // lenient id resolution ("z-image" → "z-image/turbo")
1650
+ aspect_ratio = await resolveCatalogAspectRatio(client, model, aspect_ratio, 'video_to_video');
1629
1651
  if (!source_video) throw new Error('source_video is required');
1630
1652
 
1631
1653
  const isUrl = /^https?:\/\//i.test(source_video);
@@ -1911,7 +1933,7 @@ function registerGenerateTools(server, client, options = {}) {
1911
1933
 
1912
1934
  // ── reframe ────────────────────────────────────────────
1913
1935
  aspect_ratio: z.string().optional()
1914
- .describe('Target aspect ratio (e.g. "16:9", "9:16", "1:1", "4:3"). Required for operation="reframe". Ignored by "zoom_out" — size that expansion with `zoom_out_percentage` or the `expand_*` pixel args.'),
1936
+ .describe(aspectRatioDescribe() + ' Required for operation="reframe". Ignored by "zoom_out" — size that expansion with `zoom_out_percentage` or the `expand_*` pixel args.'),
1915
1937
 
1916
1938
  // ── zoom_out (outpaint / expand) ───────────────────────
1917
1939
  zoom_out_percentage: z.number().optional()
@@ -1968,6 +1990,7 @@ function registerGenerateTools(server, client, options = {}) {
1968
1990
  // removebg / …), each operation with its own model family — there is no single
1969
1991
  // catalog type to disambiguate against.
1970
1992
  model = await canonicalModelId(client, model);
1993
+ aspect_ratio = await resolveCatalogAspectRatio(client, model, aspect_ratio);
1971
1994
 
1972
1995
  // Basic validation
1973
1996
  if (operation === 'reframe' && !aspect_ratio) throw new Error('aspect_ratio is required for reframe');
@@ -2061,7 +2084,7 @@ function registerGenerateTools(server, client, options = {}) {
2061
2084
 
2062
2085
  // ── reframe ────────────────────────────────────────────
2063
2086
  aspect_ratio: z.string().optional()
2064
- .describe('Target aspect ratio (e.g. "16:9", "9:16", "1:1"). Required for operation="reframe".'),
2087
+ .describe(aspectRatioDescribe() + ' Required for operation="reframe".'),
2065
2088
  grid_position_x: z.number().optional()
2066
2089
  .describe('Horizontal position (0.0–1.0) of the original content within the reframed canvas. Used with "reframe". Default: 0.5 (center).'),
2067
2090
  grid_position_y: z.number().optional()
@@ -2134,6 +2157,7 @@ function registerGenerateTools(server, client, options = {}) {
2134
2157
  // removebg / …), each operation with its own model family — there is no single
2135
2158
  // catalog type to disambiguate against.
2136
2159
  model = await canonicalModelId(client, model);
2160
+ aspect_ratio = await resolveCatalogAspectRatio(client, model, aspect_ratio);
2137
2161
 
2138
2162
  // Validation
2139
2163
  if (operation === 'magic_edit' && !prompt) throw new Error('prompt is required for magic_edit');
@@ -42,6 +42,7 @@ function registerMoodboardTools(server, client, options = {}) {
42
42
  // API returns thumbnail_url + images[] (sdk listMoodboards) — both
43
43
  // previous keys were wrong, so the fallback never fired either.
44
44
  thumbnail: mb.thumbnail_url || mb.thumbnail || (Array.isArray(mb.images) ? mb.images[0] : undefined),
45
+ url: mb.thumbnail_url || mb.thumbnail || (Array.isArray(mb.images) ? mb.images[0] : undefined),
45
46
  media_type: 'image',
46
47
  use_hint: 'Apply moodboard "{TITLE}" (moodboard_id: {ID}) to my next generation.'
47
48
  })),