@kolbo/mcp 1.72.2 → 1.72.4

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.72.2",
3
+ "version": "1.72.4",
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": {
@@ -1,6 +1,6 @@
1
1
  # AUTO-GENERATED — do not edit
2
2
 
3
- This tree is mirrored from kolbo-code@94bfcae, the single source of truth.
3
+ This tree is mirrored from kolbo-code@6d0fcfe, the single source of truth.
4
4
  Canonical source: packages/opencode/skills/kolbo/
5
5
  Distribution: .github/workflows/sync-skill-to-plugin.yml
6
6
 
@@ -16,9 +16,50 @@ Read `.kolbo/production.md` **before** acting on any of these signals:
16
16
 
17
17
  If the file is missing and the user is referencing prior media, ask the user — do not guess from chat.
18
18
 
19
+ ## ⚠️ Approval gates — the user is usually still iterating (READ THIS FIRST)
20
+
21
+ Most media work is an **approval loop**, not a single shot. The user generates, looks, asks for another take, and keeps going until satisfied. This is the normal case for **images, image sets, Visual DNAs, moodboards, and videos** alike.
22
+
23
+ The log records **what the user approved** — not everything you produced. Getting this wrong is expensive in both directions: log too eagerly and take 3 of 7 is enshrined as "the character"; log too late and the approved URL is gone after compaction.
24
+
25
+ **The loop:**
26
+
27
+ 1. **Generate** candidates.
28
+ 2. **Present them so the user can actually judge.** Never ask "approve?" over bare URLs or ids — the user cannot see those. Use the widget-carrying tools:
29
+ - Visual DNAs → `list_visual_dnas` (renders a thumbnail media grid; `create_visual_dna` returns text only, so follow it with this)
30
+ - Moodboards → `list_moodboards`
31
+ - Images / videos / audio → show the returned URLs as markdown images/links
32
+ Say plainly which ones are in play, e.g. "created `@maya` and `@maya_alt` — here they are".
33
+ 3. **Ask for a decision** and name the options ("keep the first, redo the second, or both?").
34
+ 4. **Repeat** until the user is satisfied. Log nothing as approved during this stage.
35
+ 5. **On approval → update `.kolbo/production.md` immediately**, in the same turn, before your next tool call or final reply.
36
+
37
+ **Never write an artifact into the log as approved without the user's approval.**
38
+
39
+ **If the user genuinely doesn't care** — "whatever you think", "you pick", "don't care", or they hand you the whole job — then **you decide**. Choose, say in one line which you picked and why, and log it as usual with `(agent-selected)`. Do not stall a production waiting for an approval the user has already delegated to you.
40
+
41
+ **Don't lose candidate URLs while iterating.** Recording a candidate is not the same as claiming approval, and compaction will eat unlogged URLs. Park in-flight takes under a `#### Candidates (pending approval)` bullet, and on approval promote the winner to a normal entry and mark the rest `(rejected)`:
42
+
43
+ ```md
44
+ 2. **Rainy street walk** — neon reflections, slow dolly
45
+ #### Candidates (pending approval)
46
+ - take 1: https://...02-rain-a.png (flux-2-pro, 2026-08-17)
47
+ - take 2: https://...02-rain-b.png (flux-2-pro, 2026-08-17)
48
+ ```
49
+
50
+ after the user picks take 2:
51
+
52
+ ```md
53
+ 2. **Rainy street walk** — neon reflections, slow dolly
54
+ - still: https://...02-rain-b.png (flux-2-pro · 1K, approved 2026-08-17)
55
+ - take 1: https://...02-rain-a.png (rejected 2026-08-17)
56
+ ```
57
+
58
+ **Re-confirm state after a long loop.** When a session has churned through many takes, restate the approved set before moving on — "so we're locked on: @maya, moodboard #noir, scenes 1-3" — and make the log match. An approval loop that ends without a written-down approved state is how the wrong asset ships.
59
+
19
60
  ## When to WRITE to it
20
61
 
21
- **Immediately after every successful generation tool call**, before your next tool call or your final reply. The runtime will inject a reminder after generation tool results — treat that as a hard rule, not a suggestion.
62
+ **Immediately once an artifact is approved** (see the approval gates above), before your next tool call or your final reply. The runtime will inject a reminder after generation tool results — treat that as a hard rule, not a suggestion. Where there is no approval loop — the user asked for one thing and got it, or delegated the choice to you — approval is implicit and you log right away.
22
63
 
23
64
  Tools that REQUIRE logging:
24
65
  - `generate_image`, `generate_image_edit`, `edit_image`
@@ -52,6 +93,8 @@ Stub for first creation:
52
93
 
53
94
  **Brief:** <paraphrase of user's overall goal in 1-3 sentences>
54
95
  **Now working on:** <the immediate next step>
96
+ **Approved:** <locked assets — DNAs, moodboards, scenes; "nothing yet" if still iterating>
97
+ **Awaiting approval:** <what you've presented and are waiting on; omit when nothing is pending>
55
98
  **Last updated:** <ISO date>
56
99
 
57
100
  ---
@@ -107,6 +150,8 @@ When a user request supersedes a previous artifact (e.g., "redo scene 2 with mor
107
150
  4. **Do not log failures.** Only successful generations.
108
151
  5. **Resolve user references via the log, not chat history.** If the user says "scene 3," use the URL the log says is scene 3, even if a later tool_result mentioned a different URL.
109
152
  6. **One file per workspace.** Multiple concurrent productions go under separate `## Production: <name>` headings inside the same file.
153
+ 7. **Approved state is user-granted, never assumed.** A generation succeeding is not approval. Only the user's "yes" — or their explicit delegation of the choice to you — promotes a candidate to an approved entry. Silence is not approval; neither is the user moving on to another topic.
154
+ 8. **The `## 🎯 Now` block names what is locked.** Keep an `**Approved:**` line there listing the currently-approved cast, DNAs, moodboards, and scenes, so the approved state survives compaction and is the first thing you read next session.
110
155
 
111
156
  ## Bulk Generation Entry Shape
112
157
 
@@ -207,7 +207,7 @@ Read `max_visual_dna` from `list_models` for the exact cap, AND `supports_visual
207
207
 
208
208
  **Step 1 — Generate both images in parallel (one `generate_image` call each, fire simultaneously):**
209
209
 
210
- 1. **4-angle character sheet** — prompt: `"[character description], character reference sheet showing front view, back view, left side view, right side view, four panels arranged in a 2x2 grid, neutral solid background, full body, photorealistic"`, aspect ratio `16:9`
210
+ 1. **4-angle character sheet** — prompt: `"[character description], character reference sheet showing front view, back view, left side view, right side view, four panels arranged in a 2x2 grid, neutral solid background, full body, photorealistic"`, aspect ratio `16:9` (or `3:2` — always landscape, see the aspect-ratio rule below)
211
211
  2. **Close-up portrait** — prompt: `"[character description], close-up portrait, face and shoulders, neutral solid background, soft studio lighting, photorealistic"`, aspect ratio `1:1`
212
212
 
213
213
  **Step 2 — Call `create_visual_dna`** with:
@@ -250,3 +250,13 @@ Tools: `list_visual_dna_folders`, `create_visual_dna_folder` (`name`, optional h
250
250
  - When the user is about to create a **character** DNA, proactively OFFER it: "want me to generate a character sheet first? It makes the character far more consistent and costs a few credits." Run it only on a yes.
251
251
  - Flow: `generate_character_sheet {image_urls}` → show the sheet → `create_visual_dna {name, images, character_sheet_url: <url>}`.
252
252
  - For non-character DNAs (style/product/environment), skip it.
253
+
254
+ ### ⚠️ Aspect ratio — character sheets and bibles are LANDSCAPE
255
+
256
+ Default every character sheet, turnaround, and character/production **bible** sheet to **`3:2` or `16:9`** unless the user asks for something else.
257
+
258
+ These are multi-panel grids laid out side by side — front, back, left, right, plus detail callouts. A square or portrait frame forces the panels to stack, which shrinks each one and costs the engine the very facial and body detail the sheet exists to capture. Landscape gives each panel usable width.
259
+
260
+ - Character sheet / turnaround / bible sheet → `3:2` or `16:9`
261
+ - Close-up portrait reference → `1:1` (a single panel, so the grid logic doesn't apply)
262
+ - Only deviate when the user explicitly names a different ratio.
package/src/apps/index.js CHANGED
@@ -447,6 +447,10 @@ const TOOL_WIDGETS = {
447
447
  generate_speech: UI.generation,
448
448
  generate_sound: UI.generation,
449
449
  generate_3d: UI.generation,
450
+ // Declared explicitly so Apps hosts prepare the card from tools/list rather
451
+ // than inferring it from the `generate_*` name; result-level _meta alone is
452
+ // not enough for hosts that read the declaration (see the note below).
453
+ generate_character_sheet: UI.generation,
450
454
  edit_image: UI.generation,
451
455
  edit_video: UI.generation,
452
456
  // transcript viewer
package/src/apps/theme.js CHANGED
@@ -85,6 +85,15 @@ body {
85
85
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
86
86
  .k-prompt.k-clamped, .k-caption.k-clamped { cursor: pointer; }
87
87
  .k-prompt.expanded { -webkit-line-clamp: unset; }
88
+ /* @VisualDNA / #Moodboard mentions are load-bearing prompt syntax, not prose —
89
+ the server resolves them to the actual asset. Mark them so a glance at the
90
+ prompt shows which references it pulls in. */
91
+ .k-mention {
92
+ display: inline; padding: 1px 5px; border-radius: 5px;
93
+ background: var(--brand-soft); color: var(--brand);
94
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
95
+ font-size: 0.94em; font-weight: 500;
96
+ }
88
97
  /* Single-line media caption (scene / batch prompt under the viewer) */
89
98
  .k-caption { font-size: 11px; color: var(--text-faint); margin: 2px 2px 0;
90
99
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -127,7 +127,7 @@ function boot(sc) {
127
127
  if (isListPayload(sc, sc.tool)) return renderList(sc);
128
128
  state = sc;
129
129
  el('tool-title').textContent = TOOL_TITLES[sc.tool] || 'Generation';
130
- el('prompt').textContent = sc.prompt || '';
130
+ el('prompt').innerHTML = promptHTML(sc.prompt);
131
131
  el('prompt').style.display = sc.prompt ? '' : 'none';
132
132
  makeExpandable(el('prompt'));
133
133
  renderChips(sc);
@@ -144,19 +144,44 @@ function boot(sc) {
144
144
  function modelLabel(sc) { return sc.model_name || sc.model; }
145
145
  function voiceLabel(sc) { return sc.voice_name || sc.voice || (sc.settings || {}).voice; }
146
146
 
147
+ // @VisualDNA / #Moodboard mentions are the tag syntax the server resolves into
148
+ // real reference assets — rendering them as flat prose hid the single most
149
+ // consequential part of the prompt. Escape FIRST, then wrap: the pattern only
150
+ // matches after a boundary, so an email or a #fff hex never lights up.
151
+ var MENTION_RE = /(^|[\s([{"'>])([@#][A-Za-z][\w-]*)/g;
152
+ // #ff8800 / #fff are hex colors, and prompts are full of them. A moodboard tag
153
+ // that happens to be 3 or 6 hex letters loses this coin flip; a grading note
154
+ // mistaken for a moodboard is the worse read.
155
+ var HEX_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
156
+ function promptHTML(text) {
157
+ return esc(text || '').replace(MENTION_RE, function (m, pre, tag) {
158
+ if (HEX_RE.test(tag)) return m;
159
+ return pre + '<span class="k-mention">' + tag + '</span>';
160
+ });
161
+ }
162
+
147
163
  function renderChips(sc) {
148
164
  var h = modelChipHTML(modelLabel(sc), sc.model_icon);
149
165
  var s = sc.settings || {};
150
166
  if (sc.kind) h += chip(iconFor(sc.kind) + ' ' + sc.kind);
151
- if (s.duration) h += chip(ICONS.clock + ' ' + fmtDur(s.duration));
167
+ if (s.duration) h += chip(ICONS.clock + ' ' + fmtDur(s.duration) + (s.shots > 1 ? ' · ' + s.shots + ' shots' : ''));
168
+ else if (s.shots > 1) h += chip(s.shots + ' shots');
152
169
  if (s.resolution) h += chip(esc(s.resolution));
153
170
  if (s.aspect_ratio) h += chip(esc(s.aspect_ratio));
154
171
  if (s.quality) h += chip(esc(s.quality) + ' quality');
155
172
  if (s.enhance_prompt) h += chip(ICONS.sparkle + ' enhanced');
156
173
  if (s.web_search) h += chip('web search');
157
- if (s.visual_dna) h += chip(s.visual_dna + ' Visual DNA');
158
- if (s.moodboard) h += chip('moodboard');
159
- if (s.preset) h += chip('preset');
174
+ // Ids where we have them (title = the id, so it can be copied / reused),
175
+ // falling back to the old count/boolean shape for payloads generated before
176
+ // the ids were carried.
177
+ var dnaIds = s.visual_dna_ids || [];
178
+ if (dnaIds.length) h += chipT(dnaIds.length + ' Visual DNA', dnaIds.join('\\n'));
179
+ else if (s.visual_dna) h += chip(s.visual_dna + ' Visual DNA');
180
+ var mbIds = s.moodboard_ids || (s.moodboard_id ? [s.moodboard_id] : []);
181
+ if (mbIds.length) h += chipT(mbIds.length > 1 ? mbIds.length + ' moodboards' : 'moodboard', mbIds.join('\\n'));
182
+ else if (s.moodboard) h += chip('moodboard');
183
+ if (s.preset_id) h += chipT('preset', s.preset_id);
184
+ else if (s.preset) h += chip('preset');
160
185
  if (s.cinematic) h += chip('cinematic');
161
186
  if (s.audio) h += chip(ICONS.sound + ' audio');
162
187
  var voice = voiceLabel(sc);
@@ -176,6 +201,11 @@ function renderChips(sc) {
176
201
  el('chips').innerHTML = h;
177
202
  }
178
203
  function chip(inner) { return '<span class="k-chip">' + inner + '</span>'; }
204
+ // Same chip with a hover title — used to surface the asset id behind a
205
+ // "2 Visual DNA" / "preset" label without spending chip width on it.
206
+ function chipT(inner, title) {
207
+ return '<span class="k-chip" title="' + esc(title) + '">' + inner + '</span>';
208
+ }
179
209
  function iconFor(kind) {
180
210
  switch (kind) {
181
211
  case 'image': return ICONS.image;
@@ -841,8 +871,8 @@ function bootPre(toolName, args) {
841
871
  }
842
872
  el('tool-title').textContent = TOOL_TITLES[toolName] || 'Generation';
843
873
  if (args && (args.prompt || args.text || (Array.isArray(args.prompts) && args.prompts.length))) {
844
- el('prompt').textContent = args.prompt || args.text ||
845
- (args.prompts.length + ' prompts — ' + args.prompts.join(' · '));
874
+ el('prompt').innerHTML = promptHTML(args.prompt || args.text ||
875
+ (args.prompts.length + ' prompts — ' + args.prompts.join(' · ')));
846
876
  el('prompt').style.display = '';
847
877
  makeExpandable(el('prompt'));
848
878
  }
@@ -101,16 +101,41 @@ async function pollBatch(client, batch, { interval, timeout }) {
101
101
  // only values that were really supplied ever surface. This used to be
102
102
  // `{ resolution, aspect_ratio }` only — `quality` (and every knob below it) was
103
103
  // silently dropped, so three calls at low/medium/high rendered identical cards.
104
+ // `visual_dna`/`moodboard`/`preset` used to collapse to a count and two
105
+ // booleans, which told nobody WHICH asset was applied — not the user reading
106
+ // the card, and not the agent reading the tool result, so a follow-up call
107
+ // could not reuse the same DNA or preset without re-listing. Carry the ids.
108
+ const refSettings = (a = {}) => ({
109
+ enhance_prompt: a.enhance_prompt || undefined,
110
+ web_search: a.enable_web_search || undefined,
111
+ visual_dna_ids: (a.visual_dna_ids && a.visual_dna_ids.length) ? a.visual_dna_ids : undefined,
112
+ moodboard_id: a.moodboard_id || undefined,
113
+ moodboard_ids: (a.moodboard_ids && a.moodboard_ids.length) ? a.moodboard_ids : undefined,
114
+ preset_id: a.preset_id || undefined,
115
+ cinematic: a.cinematic ? true : undefined,
116
+ });
117
+
104
118
  const imageSettings = (a = {}) => ({
105
119
  resolution: a.resolution,
106
120
  aspect_ratio: a.aspect_ratio,
107
121
  quality: a.quality,
108
- enhance_prompt: a.enhance_prompt || undefined,
109
- web_search: a.enable_web_search || undefined,
110
- visual_dna: (a.visual_dna_ids && a.visual_dna_ids.length) || undefined,
111
- moodboard: a.moodboard_id ? true : undefined,
112
- preset: a.preset_id ? true : undefined,
113
- cinematic: a.cinematic ? true : undefined,
122
+ ...refSettings(a),
123
+ });
124
+
125
+ // Same block for the video tools, which carried only { duration, resolution,
126
+ // aspect_ratio } — a DNA-anchored video card showed no sign a DNA was in play.
127
+ const videoSettings = (a = {}) => ({
128
+ // Multi-shot elements calls usually set shot COUNT and let the model take the
129
+ // default per-shot length, so `duration` came through undefined and the card
130
+ // showed model/resolution/ratio but never how long the video actually is —
131
+ // the one number the user picked and is paying for. Fall back to what the API
132
+ // echoed back on start, then to per-shot × shots.
133
+ duration: a.duration ?? a.reported_duration ?? (a.shot_duration && a.shots ? a.shot_duration * a.shots : undefined),
134
+ ...(a.shots > 1 ? { shots: a.shots } : {}),
135
+ resolution: a.resolution,
136
+ aspect_ratio: a.aspect_ratio,
137
+ ...(a.mode ? { mode: a.mode } : {}),
138
+ ...refSettings(a),
114
139
  });
115
140
 
116
141
  const promptsField = (what) => z.array(z.string()).max(MAX_BATCH_PROMPTS).optional().describe(
@@ -220,9 +245,11 @@ function registerGenerateTools(server, client, options = {}) {
220
245
  'generate_image_edit',
221
246
  'THE tool for ANY prompt-driven / content edit of an existing image — changing the scene ("make it night", "change the sky to sunset"), adding/removing/replacing objects, restyling, recoloring, compositing, or any "edit this image to…" request. This is the image-editing equivalent of generate_image and runs on strong dedicated editing models (nano-banana-2, gpt-image-2). Provide the source image URL(s) in `source_images` and the instruction in `prompt`. Supports Visual DNA profiles, moodboards, and Kolbo image-editing presets. PRESET CONTRACT: if the user requests a preset, call list_presets type="image_edit" and pass its exact id as `preset_id`; never silently omit it. Do NOT use `edit_image` for these — that tool is only for mechanical enhancements (upscale/reframe/remove-background/skin). For a brand-new image from scratch, use generate_image. Returns the edited image URL(s) when complete.',
222
247
  {
223
- prompt: z.string().describe('Description of the edit to apply (e.g., "remove the background", "change the sky to sunset")'),
248
+ prompt: z.string().optional().describe('Description of the edit to apply (e.g., "remove the background", "change the sky to sunset"). Required unless `prompts` is provided.'),
249
+ prompts: promptsField('edits of the SAME source images'),
224
250
  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.'),
225
251
  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.'),
252
+ 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.**'),
226
253
  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"'),
227
254
  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.'),
228
255
  num_images: z.number().optional().describe('Number of output images. Default: 1'),
@@ -230,23 +257,45 @@ function registerGenerateTools(server, client, options = {}) {
230
257
  moodboard_id: z.string().optional().describe('Moodboard ID whose master_prompt and style_guide should be applied.'),
231
258
  enable_web_search: z.boolean().optional().describe('Enable web-search grounding. Default: false'),
232
259
  resolution: z.string().optional().describe('Image resolution tier: "1K" / "2K" / "3K" / "4K". Model-dependent — call list_models and read supported_resolutions. Default: "1K" for most edit models.'),
260
+ quality: z.string().optional().describe('Quality tier for edit models that support it (e.g. "low", "medium", "high", "auto"). Check list_models → supported_qualities on the chosen model. "auto" is normalised to "medium" on gpt-image-2. Omit to use the model default.'),
233
261
  preset_id: z.string().optional().describe('Exact preset ID from list_presets type="image_edit" to apply an image-editing preset. If the user requests a preset, resolve and pass it; do not silently omit it.'),
234
262
  cinematic: CINEMATIC_SCHEMA,
235
263
  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 edit ungraded.'),
236
264
  project_id: projectIdField,
237
265
  session_id: sessionIdField
238
266
  },
239
- async ({ prompt, model, source_images, aspect_ratio, enhance_prompt = false, num_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, preset_id, cinematic, skip_color_palette, project_id, session_id }) => {
267
+ 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 }) => {
268
+ if (!prompt && !(prompts && prompts.length)) throw new Error('Provide prompt or prompts');
240
269
  model = await canonicalModelId(client, model, 'image_editing'); // lenient id resolution ("z-image" → "z-image/turbo")
241
- const gen = await client.post('/v1/generate/image-edit', {
242
- prompt, model, source_images, aspect_ratio, enhance_prompt, num_images,
243
- visual_dna_ids, moodboard_id, enable_web_search, resolution, preset_id, cinematic, skip_color_palette, project_id, session_id
244
- });
270
+ const shared = {
271
+ model, source_images, reference_images, aspect_ratio, enhance_prompt,
272
+ visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id, session_id
273
+ };
274
+ const settings = imageSettings(shared);
275
+
276
+ // Batch mode: N different edit instructions against the SAME source
277
+ // images, one widget owning every generation id. Same contract as
278
+ // generate_image — without it, "give me 4 variations of this edit" came
279
+ // back as four stacked cards.
280
+ if (prompts && prompts.length) {
281
+ const batch = await submitBatch(prompts, (p) => client.post('/v1/generate/image-edit', { ...shared, prompt: p }));
282
+ if (ui()) return uiGenerating({
283
+ tool: 'generate_image_edit', kind: 'image', gen: batch.ok[0].gen, client, model,
284
+ count: batch.ids.length, settings,
285
+ generation_ids: batch.ids, prompts: batch.ok.map((o) => o.prompt),
286
+ failed_submissions: batch.failed,
287
+ status_args: { generation_ids: batch.ids, wait: true },
288
+ reference_images: source_images
289
+ });
290
+ return pollBatch(client, batch, { interval: (batch.ok[0].gen.poll_interval_hint || 3) * 1000, timeout: 240000 });
291
+ }
292
+
293
+ const gen = await client.post('/v1/generate/image-edit', { ...shared, prompt, num_images });
245
294
 
246
295
  if (ui()) return uiGenerating({
247
296
  tool: 'generate_image_edit', kind: 'image', gen, client, model, prompt,
248
297
  count: num_images,
249
- settings: imageSettings({ resolution, aspect_ratio, enhance_prompt, enable_web_search, visual_dna_ids, moodboard_id, preset_id, cinematic }),
298
+ settings,
250
299
  reference_images: source_images
251
300
  });
252
301
 
@@ -308,7 +357,7 @@ function registerGenerateTools(server, client, options = {}) {
308
357
  if (ui()) return uiGenerating({
309
358
  tool: 'generate_creative_director', kind: 'scenes', gen, client, model, prompt,
310
359
  count: scene_count || 4,
311
- settings: { duration, resolution, aspect_ratio, mode: workflow_type || 'image' },
360
+ settings: videoSettings({ duration, resolution, aspect_ratio, mode: workflow_type || 'image', enhance_prompt, visual_dna_ids, moodboard_id, moodboard_ids }),
312
361
  reference_images,
313
362
  poll_tool: 'get_creative_director_status',
314
363
  status_args: { generation_id: gen.generation_id, wait: true }
@@ -462,7 +511,7 @@ function registerGenerateTools(server, client, options = {}) {
462
511
  const batch = await submitBatch(prompts, (p) => client.post('/v1/generate/video', { ...shared, prompt: p }));
463
512
  if (ui()) return uiGenerating({
464
513
  tool: 'generate_video', kind: 'video', gen: batch.ok[0].gen, client, model,
465
- count: batch.ids.length, settings: { duration, resolution, aspect_ratio },
514
+ count: batch.ids.length, settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, preset_id }),
466
515
  generation_ids: batch.ids, prompts: batch.ok.map((o) => o.prompt),
467
516
  failed_submissions: batch.failed,
468
517
  status_args: { generation_ids: batch.ids, wait: true },
@@ -475,7 +524,7 @@ function registerGenerateTools(server, client, options = {}) {
475
524
 
476
525
  if (ui()) return uiGenerating({
477
526
  tool: 'generate_video', kind: 'video', gen, client, model, prompt,
478
- settings: { duration, resolution, aspect_ratio },
527
+ settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, preset_id }),
479
528
  reference_images
480
529
  });
481
530
 
@@ -546,7 +595,7 @@ function registerGenerateTools(server, client, options = {}) {
546
595
  const batch = await submitBatch(items, (it) => client.post('/v1/generate/video/from-image', { ...shared, image_url: it.image_url, prompt: it.prompt }));
547
596
  if (ui()) return uiGenerating({
548
597
  tool: 'generate_video_from_image', kind: 'video', gen: batch.ok[0].gen, client, model,
549
- count: batch.ids.length, settings: { duration, resolution, aspect_ratio },
598
+ count: batch.ids.length, settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, visual_dna_ids }),
550
599
  generation_ids: batch.ids, prompts: batch.ok.map((o) => o.prompt),
551
600
  failed_submissions: batch.failed,
552
601
  status_args: { generation_ids: batch.ids, wait: true },
@@ -559,7 +608,7 @@ function registerGenerateTools(server, client, options = {}) {
559
608
 
560
609
  if (ui()) return uiGenerating({
561
610
  tool: 'generate_video_from_image', kind: 'video', gen, client, model, prompt,
562
- settings: { duration, resolution, aspect_ratio },
611
+ settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, visual_dna_ids }),
563
612
  reference_images: [image_url]
564
613
  });
565
614
 
@@ -1050,7 +1099,12 @@ function registerGenerateTools(server, client, options = {}) {
1050
1099
 
1051
1100
  if (ui()) return uiGenerating({
1052
1101
  tool: 'generate_elements', kind: 'video', gen: startResponse, client, model, prompt,
1053
- settings: { duration, resolution, aspect_ratio },
1102
+ settings: videoSettings({
1103
+ duration,
1104
+ reported_duration: startResponse?.duration ?? startResponse?.result?.duration,
1105
+ shots: multi_shot_count ?? (Array.isArray(multi_shots) ? multi_shots.length : undefined),
1106
+ resolution, aspect_ratio, enhance_prompt, visual_dna_ids, preset_id,
1107
+ }),
1054
1108
  reference_images: [
1055
1109
  ...(reference_images || []),
1056
1110
  ...(keyframes || []).map((keyframe) => keyframe.image_url),
@@ -1138,7 +1192,7 @@ function registerGenerateTools(server, client, options = {}) {
1138
1192
 
1139
1193
  if (ui()) return uiGenerating({
1140
1194
  tool: 'generate_first_last_frame', kind: 'video', gen: startResponse, client, model, prompt,
1141
- settings: { duration, resolution, aspect_ratio },
1195
+ settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, visual_dna_ids }),
1142
1196
  reference_images: [first_frame_url || first_frame, last_frame_url || last_frame]
1143
1197
  .filter((source) => /^https?:\/\//i.test(source || ''))
1144
1198
  });
@@ -1359,7 +1413,7 @@ function registerGenerateTools(server, client, options = {}) {
1359
1413
  if (ui()) return uiGenerating({
1360
1414
  tool: 'generate_video_from_video', kind: 'video', gen: startResponse, client, model,
1361
1415
  prompt: prompt || (preset ? `Subtitles preset: ${preset}` : undefined),
1362
- settings: { duration, resolution, aspect_ratio, mode: preset ? 'subtitles' : 'restyle' },
1416
+ settings: videoSettings({ duration, resolution, aspect_ratio, mode: preset ? 'subtitles' : 'restyle', enhance_prompt, visual_dna_ids }),
1363
1417
  reference_images: [...(reference_images || []), ...(elements || [])]
1364
1418
  });
1365
1419
 
@@ -6,7 +6,7 @@
6
6
  const { z } = require('zod');
7
7
  const FormData = require('form-data');
8
8
  const { resolveToBuffer, DEFAULT_MAX_FILE_MB, compactList } = require('./_shared');
9
- const { UI, uiResult, listResult, appsEnabled } = require('../apps');
9
+ const { UI, uiResult, listResult } = require('../apps');
10
10
 
11
11
  // How many tiles the media grid renders. A rendering limit only — the text
12
12
  // payload always carries the full page, and `total` reports the real library
@@ -65,8 +65,6 @@ function uploadTicketPayload(ticket) {
65
65
  }
66
66
 
67
67
  function registerMediaTools(server, client, options = {}) {
68
- const ui = () => appsEnabled(server, options);
69
-
70
68
  // `opts.apps` is set only by kolbo-api's per-request server (see createServer
71
69
  // in ../index.js), which makes it a TRANSPORT signal — deliberately not
72
70
  // `appsEnabled()`, which also returns true for stdio hosts that advertise UI.
@@ -101,24 +99,27 @@ function registerMediaTools(server, client, options = {}) {
101
99
  expires_in_seconds: ticket.expires_in,
102
100
  };
103
101
 
104
- if (ui()) {
105
- // upload_ui_url: top-level page for Claude iOS/Android in-iframe
106
- // <input type=file> selections are dropped by WebKit (see upload widget).
107
- const uploadUiUrl = ticket.upload_ui_url
108
- || String(ticket.upload_url || '').replace(/\/upload\/?$/, '/upload-ui');
109
- return uiResult(UI.upload, JSON.stringify(info, null, 2), {
110
- widget: 'upload',
111
- title: purpose || 'Upload media',
112
- upload_url: ticket.upload_url,
113
- upload_ui_url: uploadUiUrl,
114
- token: ticket.token,
115
- expires_at: Date.now() + (ticket.expires_in || 900) * 1000,
116
- kinds: media_types && media_types.length ? media_types : undefined,
117
- max_files: Math.min(Math.max(Number(max_files) || 10, 1), 20),
118
- max_mb: ticket.max_file_mb || DEFAULT_MAX_FILE_MB,
119
- ...(project_id ? { project_id } : {}),
120
- });
121
- }
102
+ // Always ship structuredContent. Kolbo Code does NOT advertise MCP Apps, so
103
+ // gating the grid payload on appsEnabled() sent it text only; the host then
104
+ // rebuilt items from the compactList text, whose field names are
105
+ // `filename`/`url` not the `title`/`thumbnail` the grid renders — so every
106
+ // tile came out black and unlabelled. Same reasoning as listResult().
107
+ // upload_ui_url: top-level page for Claude iOS/Android — in-iframe
108
+ // <input type=file> selections are dropped by WebKit (see upload widget).
109
+ const uploadUiUrl = ticket.upload_ui_url
110
+ || String(ticket.upload_url || '').replace(/\/upload\/?$/, '/upload-ui');
111
+ return uiResult(UI.upload, JSON.stringify(info, null, 2), {
112
+ widget: 'upload',
113
+ title: purpose || 'Upload media',
114
+ upload_url: ticket.upload_url,
115
+ upload_ui_url: uploadUiUrl,
116
+ token: ticket.token,
117
+ expires_at: Date.now() + (ticket.expires_in || 900) * 1000,
118
+ kinds: media_types && media_types.length ? media_types : undefined,
119
+ max_files: Math.min(Math.max(Number(max_files) || 10, 1), 20),
120
+ max_mb: ticket.max_file_mb || DEFAULT_MAX_FILE_MB,
121
+ ...(project_id ? { project_id } : {}),
122
+ });
122
123
 
123
124
  // Text-only host (Claude Code, Codex CLI, Cursor): no iframe to render —
124
125
  // but these are exactly the hosts that CAN reach a filesystem, so hand
@@ -263,30 +264,33 @@ function registerMediaTools(server, client, options = {}) {
263
264
  note: 'Narrow with `type`, `category`, `project_id`, `folder_id`, or `search`; get_media returns one item in full.',
264
265
  });
265
266
 
266
- if (ui()) {
267
- // The SDK envelope reports `total_items` (see sdk/controller.js listMedia);
268
- // reading `total` always came back undefined, so the grid claimed the page
269
- // size was the whole library. Accept either, then fall back.
270
- const totalItems = pagination
271
- ? (pagination.total_items != null ? pagination.total_items : pagination.total)
272
- : null;
273
- const items = media.slice(0, GRID_CAP).map((m) => ({
274
- id: m.id,
275
- title: m.filename,
276
- subtitle: m.media_type + (m.size ? ' · ' + Math.round(m.size / 1024) + 'KB' : ''),
277
- thumbnail: m.media_type === 'image' ? m.url : (m.thumbnail_url || null),
278
- media_type: m.media_type,
279
- url: m.url,
280
- use_hint: 'Use this media library asset in my next step:\nURL: {URL}\n(id: {ID})'
281
- }));
282
- return uiResult(UI.mediaGrid, text, {
283
- widget: 'media-grid',
284
- title: 'Media Library',
285
- items,
286
- total: totalItems != null ? totalItems : media.length,
287
- shown: Math.min(media.length, GRID_CAP)
288
- });
289
- }
267
+ // Always ship structuredContent. Kolbo Code does NOT advertise MCP Apps, so
268
+ // gating the grid payload on appsEnabled() sent it text only; the host then
269
+ // rebuilt items from the compactList text, whose field names are
270
+ // `filename`/`url` not the `title`/`thumbnail` the grid renders so every
271
+ // tile came out black and unlabelled. Same reasoning as listResult().
272
+ // The SDK envelope reports `total_items` (see sdk/controller.js listMedia);
273
+ // reading `total` always came back undefined, so the grid claimed the page
274
+ // size was the whole library. Accept either, then fall back.
275
+ const totalItems = pagination
276
+ ? (pagination.total_items != null ? pagination.total_items : pagination.total)
277
+ : null;
278
+ const items = media.slice(0, GRID_CAP).map((m) => ({
279
+ id: m.id,
280
+ title: m.filename,
281
+ subtitle: m.media_type + (m.size ? ' · ' + Math.round(m.size / 1024) + 'KB' : ''),
282
+ thumbnail: m.media_type === 'image' ? m.url : (m.thumbnail_url || null),
283
+ media_type: m.media_type,
284
+ url: m.url,
285
+ use_hint: 'Use this media library asset in my next step:\nURL: {URL}\n(id: {ID})'
286
+ }));
287
+ return uiResult(UI.mediaGrid, text, {
288
+ widget: 'media-grid',
289
+ title: 'Media Library',
290
+ items,
291
+ total: totalItems != null ? totalItems : media.length,
292
+ shown: Math.min(media.length, GRID_CAP)
293
+ });
290
294
 
291
295
  return {
292
296
  content: [{
@@ -4,11 +4,10 @@
4
4
  * new OPTIONAL args only. Full rules: ../index.js top-of-file and CLAUDE.md. */
5
5
 
6
6
  const { z } = require('zod');
7
- const { UI, uiResult, appsEnabled } = require('../apps');
7
+ const { UI, uiResult } = require('../apps');
8
8
  const { projectScopeReadField } = require('./_shared');
9
9
 
10
10
  function registerMoodboardTools(server, client, options = {}) {
11
- const ui = () => appsEnabled(server, options);
12
11
  // ─── list_moodboards ───────────────────────────────────────
13
12
  server.tool(
14
13
  'list_moodboards',
@@ -29,25 +28,26 @@ function registerMoodboardTools(server, client, options = {}) {
29
28
  count: result.count || 0
30
29
  }, null, 2);
31
30
 
32
- if (ui()) {
33
- return uiResult(UI.mediaGrid, text, {
34
- widget: 'media-grid',
35
- title: 'Moodboards',
36
- items: moodboards.slice(0, 24).map(mb => ({
37
- id: mb.id,
38
- title: mb.name,
39
- // API returns thumbnail_url + images[] (sdk listMoodboards) — both
40
- // previous keys were wrong, so the fallback never fired either.
41
- thumbnail: mb.thumbnail_url || mb.thumbnail || (Array.isArray(mb.images) ? mb.images[0] : undefined),
42
- media_type: 'image',
43
- use_hint: 'Apply moodboard "{TITLE}" (moodboard_id: {ID}) to my next generation.'
44
- })),
45
- total: result.count || moodboards.length,
46
- has_more: moodboards.length > 24
47
- });
48
- }
49
-
50
- return { content: [{ type: 'text', text }] };
31
+ // Always ship structuredContent. Kolbo Code does NOT advertise MCP Apps, so
32
+ // gating the grid payload on appsEnabled() sent it text only; the host then
33
+ // rebuilt items from the compactList text, whose field names are
34
+ // `filename`/`url` — not the `title`/`thumbnail` the grid renders — so every
35
+ // tile came out black and unlabelled. Same reasoning as listResult().
36
+ return uiResult(UI.mediaGrid, text, {
37
+ widget: 'media-grid',
38
+ title: 'Moodboards',
39
+ items: moodboards.slice(0, 24).map(mb => ({
40
+ id: mb.id,
41
+ title: mb.name,
42
+ // API returns thumbnail_url + images[] (sdk listMoodboards) both
43
+ // previous keys were wrong, so the fallback never fired either.
44
+ thumbnail: mb.thumbnail_url || mb.thumbnail || (Array.isArray(mb.images) ? mb.images[0] : undefined),
45
+ media_type: 'image',
46
+ use_hint: 'Apply moodboard "{TITLE}" (moodboard_id: {ID}) to my next generation.'
47
+ })),
48
+ total: result.count || moodboards.length,
49
+ has_more: moodboards.length > 24
50
+ });
51
51
  }
52
52
  );
53
53
 
@@ -6,7 +6,11 @@
6
6
  const { z } = require('zod');
7
7
  const FormData = require('form-data');
8
8
  const { resolveToBuffer: sharedResolveToBuffer, VISUAL_DNA_MAX_BYTES, projectScopeReadField, compactList } = require('./_shared');
9
- const { UI, uiResult, listResult, appsEnabled } = require('../apps');
9
+ const { UI, uiResult, listResult } = require('../apps');
10
+
11
+ // Reference sheets are a blocking multi-panel render; the 120s client default
12
+ // aborted them mid-flight while the server finished and charged anyway.
13
+ const CHARACTER_SHEET_TIMEOUT_MS = Number(process.env.KOLBO_CHARACTER_SHEET_TIMEOUT_MS) || 600000;
10
14
 
11
15
  // Visual DNA caps reference media at 25MB per file (stricter than the
12
16
  // default _shared.resolveToBuffer cap — DNA profiles only need enough
@@ -16,7 +20,6 @@ function resolveToBuffer(source, kind) {
16
20
  }
17
21
 
18
22
  function registerVisualDnaTools(server, client, options = {}) {
19
- const ui = () => appsEnabled(server, options);
20
23
  // ─── create_visual_dna ─────────────────────────────────────
21
24
  server.tool(
22
25
  'create_visual_dna',
@@ -129,24 +132,25 @@ function registerVisualDnaTools(server, client, options = {}) {
129
132
  note: 'Narrow with `search`, `tags`, or `collection`, or pass `page`/`limit` for the rest; get_visual_dna returns one in full.',
130
133
  });
131
134
 
132
- if (ui()) {
133
- return uiResult(UI.mediaGrid, text, {
134
- widget: 'media-grid',
135
- title: 'Visual DNA Profiles',
136
- items: dnas.slice(0, 24).map(d => ({
137
- id: d.id,
138
- title: d.name,
139
- subtitle: (d.dna_type || '') + (Array.isArray(d.tags) && d.tags.length ? ' · ' + d.tags.slice(0, 3).join(', ') : ''),
140
- thumbnail: d.thumbnail_url || d.thumbnail,
141
- media_type: 'image',
142
- use_hint: 'Use Visual DNA "{TITLE}" (id: {ID}) in my next generation for character/style consistency.'
143
- })),
144
- total,
145
- has_more: result.has_more || dnas.length > 24
146
- });
147
- }
148
-
149
- return { content: [{ type: 'text', text }] };
135
+ // Always ship structuredContent. Kolbo Code does NOT advertise MCP Apps, so
136
+ // gating the grid payload on appsEnabled() sent it text only; the host then
137
+ // rebuilt items from the compactList text, whose field names are
138
+ // `filename`/`url` — not the `title`/`thumbnail` the grid renders — so every
139
+ // tile came out black and unlabelled. Same reasoning as listResult().
140
+ return uiResult(UI.mediaGrid, text, {
141
+ widget: 'media-grid',
142
+ title: 'Visual DNA Profiles',
143
+ items: dnas.slice(0, 24).map(d => ({
144
+ id: d.id,
145
+ title: d.name,
146
+ subtitle: (d.dna_type || '') + (Array.isArray(d.tags) && d.tags.length ? ' · ' + d.tags.slice(0, 3).join(', ') : ''),
147
+ thumbnail: d.thumbnail_url || d.thumbnail,
148
+ media_type: 'image',
149
+ use_hint: 'Use Visual DNA "{TITLE}" (id: {ID}) in my next generation for character/style consistency.'
150
+ })),
151
+ total,
152
+ has_more: result.has_more || dnas.length > 24
153
+ });
150
154
  }
151
155
  );
152
156
 
@@ -200,12 +204,33 @@ function registerVisualDnaTools(server, client, options = {}) {
200
204
  sheet_type: z.enum(['character', 'character_headless', 'character_bible', 'product', 'environment', 'style']).optional().describe('Sheet layout. character = front/back/face turnaround. character_headless = wardrobe/body refs with a headless front panel (use when clothing must change without fighting the face sheet). character_bible = denser production model-sheet (turnaround + faces + wardrobe + color swatches). product / environment / style = matching DNA types. Defaults to character.')
201
205
  },
202
206
  async ({ image_urls, sheet_type }) => {
203
- const result = await client.post('/v1/visual-dna/character-sheet', { image_urls, ...(sheet_type ? { sheet_type } : {}) });
207
+ // The endpoint is blocking and a 2K multi-panel sheet routinely runs past the
208
+ // 120s default: the MCP aborted while kolbo-api kept going, finished, and
209
+ // billed — the user saw "Failed" for a sheet they had already paid for.
210
+ const result = await client.post(
211
+ '/v1/visual-dna/character-sheet',
212
+ { image_urls, ...(sheet_type ? { sheet_type } : {}) },
213
+ { timeoutMs: CHARACTER_SHEET_TIMEOUT_MS },
214
+ );
215
+ // `urls` is NOT redundant with character_sheet_url. Every generation-card
216
+ // reader keys on `urls` — the MCP's own widget (apps/widgets/generation.js),
217
+ // kolbo-code's kolbo-operation.ts mediaUrls(), and its operation.js urlsOf().
218
+ // Kolbo Code mounts a generation card for ANY tool named `generate_*`, so
219
+ // returning only character_sheet_url gave the card zero URLs and it rendered
220
+ // "No output received / Failed" on top of a sheet that generated fine and was
221
+ // already billed — and "Try Again" then double-charged. `widget`/`phase` mark
222
+ // the payload as a completed generation so the card stops falling back to the
223
+ // stale `phase: "review"` envelope built before the tool ran.
224
+ const urls = result.character_sheet_url ? [result.character_sheet_url] : [];
204
225
  return {
205
226
  content: [{
206
227
  type: 'text',
207
228
  text: JSON.stringify({
208
229
  character_sheet_url: result.character_sheet_url,
230
+ urls,
231
+ widget: 'generation',
232
+ phase: 'completed',
233
+ kind: 'image',
209
234
  credits_used: result.credits_used,
210
235
  _hint: 'Show the sheet to the user, then pass character_sheet_url to create_visual_dna as that DNA\'s reference.'
211
236
  }, null, 2)