@kolbo/mcp 1.56.0 → 1.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.56.0",
3
+ "version": "1.57.0",
4
4
  "description": "Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/apps/index.js CHANGED
@@ -69,12 +69,19 @@ const WIDGET_CSP = {
69
69
  'https://fonts.googleapis.com', // Inter / JetBrains Mono stylesheet
70
70
  'https://fonts.gstatic.com', // font files
71
71
  'https://images.pexels.com', // stock thumbnails
72
+ 'https://images.unsplash.com', // stock thumbnails (Unsplash)
73
+ 'https://plus.unsplash.com', // Unsplash+ premium images
74
+ 'https://cdn.coverr.co', // Coverr video thumbnails
75
+ 'https://cdn.freesound.org', // Freesound waveform previews
72
76
  // Wildcards as a second layer for hosts that do support them.
73
77
  'https://*.kolbo.ai',
74
78
  'https://*.digitaloceanspaces.com',
75
79
  'https://*.cdn.digitaloceanspaces.com',
76
80
  'https://*.pexels.com',
77
81
  'https://*.pixabay.com',
82
+ 'https://*.unsplash.com',
83
+ 'https://*.coverr.co',
84
+ 'https://*.freesound.org',
78
85
  'https://*.sketchfab.com',
79
86
  'https://*.cloudfront.net',
80
87
  ],
package/src/apps/theme.js CHANGED
@@ -83,6 +83,12 @@ body {
83
83
  .k-body { padding: 14px 16px; }
84
84
  .k-prompt { color: var(--text-muted); font-size: 12.5px; margin-bottom: 10px; word-break: break-word;
85
85
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
86
+ .k-prompt.k-clamped, .k-caption.k-clamped { cursor: pointer; }
87
+ .k-prompt.expanded { -webkit-line-clamp: unset; }
88
+ /* Single-line media caption (scene / batch prompt under the viewer) */
89
+ .k-caption { font-size: 11px; color: var(--text-faint); margin: 2px 2px 0;
90
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
91
+ .k-caption.expanded { white-space: normal; word-break: break-word; }
86
92
 
87
93
  /* ---- Chips ---- */
88
94
  .k-chips { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 12px; }
@@ -127,6 +133,13 @@ body {
127
133
  animation: k-sweep 1.6s ease-in-out infinite;
128
134
  }
129
135
  @keyframes k-sweep { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
136
+ /* Batch grid: per-cell prompt caption + a cell that already finished */
137
+ .k-skel-cap { position: absolute; left: 0; right: 0; bottom: 0; z-index: 2;
138
+ padding: 12px 8px 6px; font-size: 10.5px; color: #fff;
139
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.65));
140
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
141
+ .k-skel.done::after { animation: none; background: none; }
142
+ .k-cell-fill { width: 100%; height: 100%; object-fit: cover; display: block; }
130
143
  .k-gen-badge {
131
144
  position: absolute; top: 10px; left: 10px; z-index: 2;
132
145
  display: inline-flex; align-items: center; gap: 6px;
@@ -71,12 +71,31 @@ var TOOL_TITLES = {
71
71
  generate_creative_director: 'Creative Director', edit_image: 'Image Edit', edit_video: 'Video Edit'
72
72
  };
73
73
 
74
+ // Long text is clamped by CSS (.k-prompt 2 lines / .k-caption 1 line). When it
75
+ // actually overflows, make it click-to-expand so the full prompt is readable.
76
+ function makeExpandable(node) {
77
+ if (!node) return;
78
+ node.classList.remove('k-clamped');
79
+ // Synchronous layout read — rAF would never fire in a hidden/backgrounded
80
+ // iframe, leaving long prompts stuck without the expand affordance.
81
+ if (node.scrollHeight > node.clientHeight + 2 || node.scrollWidth > node.clientWidth + 2) {
82
+ node.classList.add('k-clamped');
83
+ node.title = node.title || 'Show full text';
84
+ node.onclick = function () {
85
+ node.classList.toggle('expanded');
86
+ node.title = node.classList.contains('expanded') ? 'Collapse' : 'Show full text';
87
+ window.kolbo.notifySize();
88
+ };
89
+ }
90
+ }
91
+
74
92
  function boot(sc) {
75
93
  if (!sc) return;
76
94
  state = sc;
77
95
  el('tool-title').textContent = TOOL_TITLES[sc.tool] || 'Generation';
78
96
  el('prompt').textContent = sc.prompt || '';
79
97
  el('prompt').style.display = sc.prompt ? '' : 'none';
98
+ makeExpandable(el('prompt'));
80
99
  renderChips(sc);
81
100
  el('credits').textContent = sc.credits_used != null ? fmtCredits(sc.credits_used) : '';
82
101
  if (sc.phase === 'generating') renderGenerating(sc);
@@ -111,16 +130,21 @@ function iconFor(kind) {
111
130
  }
112
131
 
113
132
  /* ---------- generating ---------- */
133
+ function isBatch(sc) { return !!(sc && sc.generation_ids && sc.generation_ids.length > 1); }
134
+
114
135
  function renderGenerating(sc) {
115
136
  setPhaseChip('Generating', true);
116
- var n = Math.min(sc.count || 1, 4);
137
+ var n = Math.min(sc.count || 1, isBatch(sc) ? 8 : 4);
117
138
  var shape = sc.kind === 'video' || sc.kind === 'scenes' ? 'video' : (sc.kind === 'audio' ? 'video' : 'square');
118
139
  var cells = '';
119
140
  for (var i = 0; i < n; i++) {
120
- cells += '<div class="k-skel ' + shape + '">' +
121
- (i === 0 ? '<span class="k-gen-badge"><span class="k-spin"></span>Generating</span>' : '') + '</div>';
141
+ var cap = (sc.prompts && sc.prompts[i])
142
+ ? '<span class="k-skel-cap" title="' + esc(sc.prompts[i]) + '">' + esc(sc.prompts[i]) + '</span>' : '';
143
+ cells += '<div class="k-skel ' + shape + '" data-cell="' + i + '">' +
144
+ (i === 0 ? '<span class="k-gen-badge"><span class="k-spin"></span>Generating</span>' : '') + cap + '</div>';
122
145
  }
123
- el('stage').innerHTML = '<div class="k-gen-grid n' + n + '">' + cells + '</div>';
146
+ // Grid class caps at n4 the auto-fill rule handles any larger batch count.
147
+ el('stage').innerHTML = '<div class="k-gen-grid n' + Math.min(n, 4) + '">' + cells + '</div>';
124
148
  renderStopButton(sc);
125
149
  schedulePoll(sc);
126
150
  }
@@ -134,6 +158,7 @@ function cancelSpec(sc) {
134
158
  var jobId = (sc.status_args && sc.status_args.job_id) || sc.generation_id;
135
159
  return jobId ? { tool: 'shorts_cancel', args: { job_id: jobId } } : null;
136
160
  }
161
+ if (isBatch(sc)) return { batch: sc.generation_ids };
137
162
  if (!sc.generation_id) return null;
138
163
  return { tool: 'cancel_generation', args: { generation_id: sc.generation_id } };
139
164
  }
@@ -150,8 +175,23 @@ function renderStopButton(sc) {
150
175
  // cannot repaint the card back into "Generating".
151
176
  cancelRequested = true;
152
177
  clearTimeout(pollTimer);
153
- window.kolbo.callTool(spec.tool, spec.args).then(function (res) {
154
- var st = structured(res) || {};
178
+ // Batch: cancel every id; report combined refund. Entries that already
179
+ // finished return cancelled:false only resume polling if ALL did.
180
+ var call = spec.batch
181
+ ? Promise.all(spec.batch.map(function (id) {
182
+ return window.kolbo.callTool('cancel_generation', { generation_id: id })
183
+ .then(function (r) { return structured(r) || {}; })
184
+ .catch(function () { return {}; });
185
+ })).then(function (sts) {
186
+ var refund = 0;
187
+ sts.forEach(function (s) { if (s.credits_refunded) refund += s.credits_refunded; });
188
+ return {
189
+ cancelled: sts.some(function (s) { return s.cancelled !== false; }),
190
+ credits_refunded: refund || undefined
191
+ };
192
+ })
193
+ : window.kolbo.callTool(spec.tool, spec.args).then(function (r) { return structured(r) || {}; });
194
+ call.then(function (st) {
155
195
  if (st.cancelled === false) {
156
196
  // Already terminal — let the normal poll path report the real outcome
157
197
  // instead of claiming a cancel that did not happen.
@@ -183,7 +223,7 @@ function renderCancelled(creditsRefunded) {
183
223
  // Tell the model, so it does not go on to report the generation as running.
184
224
  try {
185
225
  window.kolbo.updateModelContext('The user cancelled generation ' +
186
- ((state && state.generation_id) || '') + '.' + note +
226
+ ((state && (state.generation_ids || [state.generation_id]).filter(Boolean).join(', ')) || '') + '.' + note +
187
227
  ' Do not poll it or report it as in progress.');
188
228
  } catch (e) {}
189
229
  window.kolbo.notifySize();
@@ -222,6 +262,10 @@ function poll(sc) {
222
262
  if (++pollErrors >= MAX_POLL_ERRORS) return renderTrackingIssue(st.error || 'Tracking paused. The generation may still be running.');
223
263
  return schedulePoll(sc);
224
264
  }
265
+ // Batch (prompts[] fan-out): multi-id status shape { all_done, generations[] }.
266
+ if (isBatch(sc) && Array.isArray(st.generations)) {
267
+ return handleBatchStatus(sc, st);
268
+ }
225
269
  if (stateName === 'completed') {
226
270
  pollErrors = 0;
227
271
  var r = st.result || st;
@@ -257,6 +301,68 @@ function poll(sc) {
257
301
  });
258
302
  }
259
303
 
304
+ /* ---------- batch (prompts[] fan-out) ---------- */
305
+ // Each poll round resolves when every id has completed or its wait window
306
+ // closed (~3 min), so finished cells fill in per round while the rest keep
307
+ // their skeleton. When all_done, the set renders through the scenes viewer.
308
+ function handleBatchStatus(sc, st) {
309
+ pollErrors = 0;
310
+ var gens = st.generations || [];
311
+ gens.forEach(function (g, i) {
312
+ if (g.state === 'completed') fillBatchCell(sc, i, g);
313
+ });
314
+ if (!st.all_done) return schedulePoll(sc);
315
+
316
+ var scenes = [], failedCount = 0, credits = 0, haveCredits = false, allUrls = [];
317
+ gens.forEach(function (g, i) {
318
+ var r = g.result || g;
319
+ var urls = (r && r.urls) || [];
320
+ if (g.state !== 'completed' || !urls.length) { failedCount++; return; }
321
+ allUrls = allUrls.concat(urls);
322
+ var c = g.credits_used != null ? g.credits_used : (r.credits_used != null ? r.credits_used : null);
323
+ if (c != null) { credits += c; haveCredits = true; }
324
+ scenes.push({
325
+ scene_number: i + 1,
326
+ title: (sc.prompts && sc.prompts[i]) || '',
327
+ image_urls: sc.kind === 'video' ? [] : urls,
328
+ video_urls: sc.kind === 'video' ? urls : []
329
+ });
330
+ });
331
+ if (!scenes.length) return renderError('All ' + gens.length + ' generations failed');
332
+
333
+ var done = Object.assign({}, sc, {
334
+ phase: 'completed', kind: 'scenes', batch: true, scenes: scenes, urls: [],
335
+ credits_used: haveCredits ? credits : sc.credits_used
336
+ });
337
+ state = done;
338
+ el('credits').textContent = done.credits_used != null ? fmtCredits(done.credits_used) : '';
339
+ if (failedCount) setPhaseChip(failedCount + ' failed', false);
340
+ renderResult(done);
341
+ try {
342
+ window.kolbo.updateModelContext(
343
+ 'Batch generation completed (' + (sc.tool || '') + '): ' + scenes.length + ' of ' + gens.length + ' succeeded.' +
344
+ '\\nOutput URLs:\\n' + allUrls.join('\\n') +
345
+ (failedCount ? '\\nFailed: ' + failedCount : '') +
346
+ (haveCredits ? '\\nCredits used: ' + credits : ''));
347
+ } catch (e) {}
348
+ }
349
+
350
+ function fillBatchCell(sc, i, g) {
351
+ var cell = el('stage').querySelector('[data-cell="' + i + '"]');
352
+ if (!cell || cell.getAttribute('data-done')) return;
353
+ var r = g.result || g;
354
+ var u = (r.urls || [])[0];
355
+ if (!u) return;
356
+ cell.setAttribute('data-done', '1');
357
+ cell.classList.add('done');
358
+ var cap = (sc.prompts && sc.prompts[i])
359
+ ? '<span class="k-skel-cap" title="' + esc(sc.prompts[i]) + '">' + esc(sc.prompts[i]) + '</span>' : '';
360
+ cell.innerHTML = (sc.kind === 'video'
361
+ ? '<video class="k-cell-fill" src="' + esc(u) + '"' + (r.thumbnail_url ? ' poster="' + esc(r.thumbnail_url) + '"' : '') + ' muted playsinline preload="metadata"></video>'
362
+ : '<img class="k-cell-fill" src="' + esc(u) + '" alt="">') + cap;
363
+ window.kolbo.notifySize();
364
+ }
365
+
260
366
  /* ---------- results ---------- */
261
367
  function renderResult(sc) {
262
368
  clearTimeout(pollTimer);
@@ -392,7 +498,10 @@ function wireDlButtons(root) {
392
498
  function sceneItems(sc) {
393
499
  var items = [];
394
500
  (sc.scenes || []).forEach(function (scene) {
395
- var label = 'Scene ' + scene.scene_number + (scene.title ? ' ' + scene.title : '');
501
+ // Batch sets carry the raw user prompt as title no "Scene N" framing.
502
+ var label = sc.batch
503
+ ? (scene.title || 'Prompt ' + scene.scene_number)
504
+ : 'Scene ' + scene.scene_number + (scene.title ? ' — ' + scene.title : '');
396
505
  (scene.image_urls || []).forEach(function (u) { items.push({ url: u, type: 'image', label: label }); });
397
506
  (scene.video_urls || []).forEach(function (u) { items.push({ url: u, type: 'video', label: label }); });
398
507
  });
@@ -415,8 +524,9 @@ function renderScenes(sc) {
415
524
  }).join('') + '</div>';
416
525
  el('stage').innerHTML =
417
526
  '<div class="k-viewer">' + mediaHtml + dlBtnHTML(it.url) + '</div>' +
418
- '<div style="font-size:11px;color:var(--text-faint);margin:2px 2px 0">' + esc(it.label) + '</div>' +
527
+ '<div class="k-caption" id="scene-cap">' + esc(it.label) + '</div>' +
419
528
  thumbs;
529
+ makeExpandable(el('scene-cap'));
420
530
  wireDlButtons(el('stage'));
421
531
  if (it.type === 'image') {
422
532
  var main = el('scene-main');
@@ -591,9 +701,11 @@ function bootPre(toolName, args) {
591
701
  if (args) originArgs = args;
592
702
  if (state) return; // real data already arrived
593
703
  el('tool-title').textContent = TOOL_TITLES[toolName] || 'Generation';
594
- if (args && (args.prompt || args.text)) {
595
- el('prompt').textContent = args.prompt || args.text;
704
+ if (args && (args.prompt || args.text || (Array.isArray(args.prompts) && args.prompts.length))) {
705
+ el('prompt').textContent = args.prompt || args.text ||
706
+ (args.prompts.length + ' prompts — ' + args.prompts.join(' · '));
596
707
  el('prompt').style.display = '';
708
+ makeExpandable(el('prompt'));
597
709
  }
598
710
  setPhaseChip('Preparing', true);
599
711
  if (!el('stage').innerHTML) {
@@ -519,9 +519,18 @@ async function uiGenerating(p) {
519
519
  reference_image: p.reference_image,
520
520
  open_url: buildOpenUrl(p.tool, p.gen),
521
521
  };
522
+ // Batch mode (prompts[] fan-out): ONE widget tracks every id in the set.
523
+ if (Array.isArray(p.generation_ids) && p.generation_ids.length > 1) {
524
+ structured.generation_ids = p.generation_ids;
525
+ structured.prompts = p.prompts;
526
+ }
522
527
  const text = JSON.stringify({
523
528
  status: 'submitted',
524
529
  generation_id: p.gen.generation_id,
530
+ ...(Array.isArray(p.generation_ids) && p.generation_ids.length > 1
531
+ ? { batch: true, generation_ids: p.generation_ids } : {}),
532
+ ...(p.failed_submissions && p.failed_submissions.length
533
+ ? { failed_submissions: p.failed_submissions } : {}),
525
534
  _widget_note: 'A live Kolbo widget is rendering this generation for the user (progress + final result + action buttons). Tell the user it is generating and the card above will update — do NOT poll in a loop. If you need the output URLs (e.g. for a follow-up edit or a report), call get_generation_status ONCE with wait=true — it blocks until done. Tracking several generations? Pass ALL their ids in generation_ids in that one call.',
526
535
  }, null, 2);
527
536
  return uiResult(UI.generation, text, structured);
@@ -37,6 +37,46 @@ const CINEMATIC_SCHEMA = z.object({
37
37
  'validated against their dimension server-side. Dimensions are data-driven — never hardcode ids.'
38
38
  );
39
39
 
40
+ // ─── Batch fan-out (prompts[]) ──────────────────────────────────────────────
41
+ // One tool call, N DIFFERENT prompts → N generations tracked by ONE widget.
42
+ // The manual-control twin of generate_creative_director: no orchestration pass,
43
+ // the user's exact prompts verbatim. Submit failures never sink the batch —
44
+ // successful ids proceed, failed prompts are reported alongside.
45
+ const MAX_BATCH_PROMPTS = 8;
46
+ async function submitBatch(rawPrompts, submitOne) {
47
+ const prompts = rawPrompts.slice(0, MAX_BATCH_PROMPTS).map((s) => String(s).trim()).filter(Boolean);
48
+ const settled = await Promise.allSettled(prompts.map((p) => submitOne(p)));
49
+ const ok = [], failed = [];
50
+ settled.forEach((s, i) => {
51
+ if (s.status === 'fulfilled' && s.value && s.value.generation_id) ok.push({ prompt: prompts[i], gen: s.value });
52
+ else failed.push({ prompt: prompts[i], error: (s.reason && s.reason.message) || 'submit failed' });
53
+ });
54
+ if (!ok.length) throw new Error(`All ${prompts.length} batch submissions failed: ${failed[0].error}`);
55
+ return { ok, failed, ids: ok.map((o) => o.gen.generation_id) };
56
+ }
57
+
58
+ // Blocking path for text-only hosts: wait for every batch member, aggregate.
59
+ async function pollBatch(client, batch, { interval, timeout }) {
60
+ const polls = await Promise.all(batch.ids.map((id) => pollOrTimedOut(client, id, { interval, timeout })));
61
+ const generations = polls.map((p, i) => p.timedOut
62
+ ? { prompt: batch.ok[i].prompt, generation_id: batch.ids[i], status: 'processing', note: 'Still running — call get_generation_status with wait=true to collect it.' }
63
+ : { prompt: batch.ok[i].prompt, generation_id: batch.ids[i], status: 'completed', ...creditFields(polls[i].result), urls: p.result.result.urls });
64
+ return {
65
+ content: [{
66
+ type: 'text',
67
+ text: JSON.stringify({
68
+ batch: true,
69
+ generations,
70
+ failed_submissions: batch.failed.length ? batch.failed : undefined
71
+ }, null, 2)
72
+ }]
73
+ };
74
+ }
75
+
76
+ const promptsField = (what) => z.array(z.string()).optional().describe(
77
+ `BATCH MODE — several DIFFERENT prompts (2–${MAX_BATCH_PROMPTS}) generated concurrently in ONE call and rendered together in ONE combined widget. Whenever the user wants multiple distinct ${what} with their own prompts, ALWAYS pass them all here instead of making several separate calls — separate calls clutter the chat with stacked widgets. All prompts share the same model/settings. When set, \`prompt\` is ignored. For N variations of a SINGLE prompt use num_images (image tools); for an AI-planned coherent scene set use generate_creative_director.`
78
+ );
79
+
40
80
  function registerGenerateTools(server, client, options = {}) {
41
81
  // Only enabled by hosts that explicitly opt in (the remote HTTP connector).
42
82
  // stdio hosts (Kolbo Code, Claude Desktop, Cursor) leave this false, so their
@@ -49,9 +89,10 @@ function registerGenerateTools(server, client, options = {}) {
49
89
  // ─── generate_image ────────────────────────────────────────
50
90
  server.tool(
51
91
  'generate_image',
52
- 'Generate image(s) from a text prompt using Kolbo AI. Supports Visual DNA profiles (for character/style/product consistency), moodboards (for style direction), reference images (for composition guidance), batch generation (num_images), and web-search grounding. For EDITING an existing image, use generate_image_edit instead. For a coordinated multi-scene set (storyboard, ad campaign), use generate_creative_director. Returns the final image URL(s) when complete.',
92
+ 'Generate image(s) from a text prompt using Kolbo AI. Supports Visual DNA profiles (for character/style/product consistency), moodboards (for style direction), 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. 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.',
53
93
  {
54
- prompt: z.string().describe('Text description of the image to generate'),
94
+ prompt: z.string().optional().describe('Text description of the image to generate. Required unless `prompts` is provided.'),
95
+ prompts: promptsField('images'),
55
96
  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.'),
56
97
  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"'),
57
98
  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.'),
@@ -67,12 +108,29 @@ function registerGenerateTools(server, client, options = {}) {
67
108
  skip_color_palette: z.boolean().optional().describe('Opt this single call OUT of the account\'s active Color DNA palette (see list_color_palettes / activate_color_palette). By default, if the user has an active palette it strict-grades every generation automatically — pass true only when the user explicitly wants this one image ungraded.'),
68
109
  project_id: projectIdField
69
110
  },
70
- async ({ prompt, 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 }) => {
111
+ 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 }) => {
112
+ if (!prompt && !(prompts && prompts.length)) throw new Error('Provide prompt or prompts');
71
113
  model = await canonicalModelId(client, model); // lenient id resolution ("z-image" → "z-image/turbo")
72
- const gen = await client.post('/v1/generate/image', {
73
- prompt, model, aspect_ratio, enhance_prompt, num_images,
114
+ const shared = {
115
+ model, aspect_ratio, enhance_prompt,
74
116
  reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id
75
- });
117
+ };
118
+
119
+ // Batch mode: N different prompts, one widget owning all generation ids.
120
+ if (prompts && prompts.length) {
121
+ const batch = await submitBatch(prompts, (p) => client.post('/v1/generate/image', { ...shared, prompt: p }));
122
+ if (ui()) return uiGenerating({
123
+ tool: 'generate_image', kind: 'image', gen: batch.ok[0].gen, client, model,
124
+ count: batch.ids.length, settings: { resolution, aspect_ratio },
125
+ generation_ids: batch.ids, prompts: batch.ok.map((o) => o.prompt),
126
+ failed_submissions: batch.failed,
127
+ status_args: { generation_ids: batch.ids, wait: true },
128
+ reference_image: reference_images?.[0]
129
+ });
130
+ return pollBatch(client, batch, { interval: (batch.ok[0].gen.poll_interval_hint || 3) * 1000, timeout: 240000 });
131
+ }
132
+
133
+ const gen = await client.post('/v1/generate/image', { ...shared, prompt, num_images });
76
134
 
77
135
  if (ui()) return uiGenerating({
78
136
  tool: 'generate_image', kind: 'image', gen, client, model, prompt,
@@ -165,7 +223,7 @@ function registerGenerateTools(server, client, options = {}) {
165
223
  // ─── generate_creative_director ─────────────────────────────
166
224
  server.tool(
167
225
  'generate_creative_director',
168
- 'Generate 2–8 related images or videos as one coherent set from a single creative brief. Use scene_count (NOT num_images) to set the number of scenes (1–8, default 4). Use this when the user gives a general brief ("make 4 product shots", "create a storyboard") and you are planning the scenes — it handles style consistency and runs scenes in parallel. If the user explicitly provides separate prompts for each image, use parallel generate_image calls instead. Supports image and video modes (workflow_type). Visual DNA and moodboard references keep character/style consistent across every scene.',
226
+ 'Generate 2–8 related images or videos as one coherent set from a single creative brief. Use scene_count (NOT num_images) to set the number of scenes (1–8, default 4). Use this when the user gives a general brief ("make 4 product shots", "create a storyboard") and you are planning the scenes — it handles style consistency and runs scenes in parallel. If the user explicitly provides separate prompts for each image, use ONE generate_image call with the `prompts` array instead (never parallel single-prompt calls). Supports image and video modes (workflow_type). Visual DNA and moodboard references keep character/style consistent across every scene.',
169
227
  {
170
228
  prompt: z.string().describe('Creative brief or concept describing the full set of scenes to generate'),
171
229
  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.'),
@@ -318,9 +376,10 @@ function registerGenerateTools(server, client, options = {}) {
318
376
  // DNA-locked still via generate_video_from_image.
319
377
  server.tool(
320
378
  'generate_video',
321
- 'Generate a video from a text prompt using Kolbo AI. For animating an existing still image into motion, use generate_video_from_image instead. For a coordinated multi-scene video campaign, use generate_creative_director with workflow_type="video". Supports reference images (for style/composition guidance). Does NOT support Visual DNA — for character-consistent video use generate_elements or animate a DNA-locked still via generate_video_from_image. Returns the final video URL when complete.',
379
+ 'Generate a video from a text prompt using Kolbo AI. For SEVERAL different videos, pass all their prompts in `prompts` in ONE call (one combined widget) — never a series of separate calls. For animating an existing still image into motion, use generate_video_from_image instead. For a coordinated multi-scene video campaign, use generate_creative_director with workflow_type="video". Supports reference images (for style/composition guidance). Does NOT support Visual DNA — for character-consistent video use generate_elements or animate a DNA-locked still via generate_video_from_image. Returns the final video URL when complete.',
322
380
  {
323
- prompt: z.string().describe('Text description of the video to generate'),
381
+ prompt: z.string().optional().describe('Text description of the video to generate. Required unless `prompts` is provided.'),
382
+ prompts: promptsField('videos'),
324
383
  model: z.string().optional().describe('Model identifier — pick a SPECIFIC model, do NOT omit (omitting = Smart Select auto-pick, which we avoid). Strong current defaults: "seedance-2" (versatile) or "veo3" (Veo 3.1, cinematic + native audio); the Kling family (call list_models for exact ids like kling-video/v3/pro/text-to-video) is strongest for motion. Call list_models type="text_to_video" to see all options + check supported_durations / supported_aspect_ratios, and choose per the user\'s intent.'),
325
384
  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"'),
326
385
  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'),
@@ -332,11 +391,28 @@ function registerGenerateTools(server, client, options = {}) {
332
391
  skip_color_palette: z.boolean().optional().describe('Opt this single call OUT of the account\'s active Color DNA palette (see list_color_palettes / activate_color_palette). By default, if the user has an active palette it strict-grades every generation automatically — pass true only when the user explicitly wants this one video ungraded.'),
333
392
  project_id: projectIdField
334
393
  },
335
- async ({ prompt, model, aspect_ratio, duration, enhance_prompt = false, reference_images, resolution, preset_id, sound_enabled, skip_color_palette, project_id }) => {
394
+ async ({ prompt, prompts, model, aspect_ratio, duration, enhance_prompt = false, reference_images, resolution, preset_id, sound_enabled, skip_color_palette, project_id }) => {
395
+ if (!prompt && !(prompts && prompts.length)) throw new Error('Provide prompt or prompts');
336
396
  model = await canonicalModelId(client, model); // lenient id resolution ("z-image" → "z-image/turbo")
337
- const gen = await client.post('/v1/generate/video', {
338
- prompt, model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, preset_id, sound_enabled, skip_color_palette, project_id
339
- });
397
+ const shared = {
398
+ model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, preset_id, sound_enabled, skip_color_palette, project_id
399
+ };
400
+
401
+ // Batch mode: N different prompts, one widget owning all generation ids.
402
+ if (prompts && prompts.length) {
403
+ const batch = await submitBatch(prompts, (p) => client.post('/v1/generate/video', { ...shared, prompt: p }));
404
+ if (ui()) return uiGenerating({
405
+ tool: 'generate_video', kind: 'video', gen: batch.ok[0].gen, client, model,
406
+ count: batch.ids.length, settings: { duration, resolution, aspect_ratio },
407
+ generation_ids: batch.ids, prompts: batch.ok.map((o) => o.prompt),
408
+ failed_submissions: batch.failed,
409
+ status_args: { generation_ids: batch.ids, wait: true },
410
+ reference_image: reference_images?.[0]
411
+ });
412
+ return pollBatch(client, batch, { interval: (batch.ok[0].gen.poll_interval_hint || 8) * 1000, timeout: 900000 });
413
+ }
414
+
415
+ const gen = await client.post('/v1/generate/video', { ...shared, prompt });
340
416
 
341
417
  if (ui()) return uiGenerating({
342
418
  tool: 'generate_video', kind: 'video', gen, client, model, prompt,