@kolbo/mcp 1.28.0 → 1.30.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/src/index.js CHANGED
@@ -72,6 +72,8 @@ const { registerProjectTools } = require('./tools/projects');
72
72
  const { registerVoiceTools } = require('./tools/voices');
73
73
  const { registerMusicLibraryTools } = require('./tools/music_library');
74
74
  const { registerStockLibraryTools } = require('./tools/stock_library');
75
+ const { registerShortsCreatorTools } = require('./tools/shorts_creator');
76
+ const { registerApps } = require('./apps');
75
77
 
76
78
  /**
77
79
  * Build a fully-configured Kolbo MCP server (all tool groups registered)
@@ -83,6 +85,11 @@ const { registerStockLibraryTools } = require('./tools/stock_library');
83
85
  * @param {object} [opts]
84
86
  * @param {string} [opts.apiKey] Per-instance Kolbo API key (overrides env).
85
87
  * @param {string} [opts.apiBase] API base URL override.
88
+ * @param {boolean} [opts.apps] Force-enable MCP Apps widget results. Set by
89
+ * the kolbo-api remote connector (claude.ai),
90
+ * whose stateless transport hides client
91
+ * capabilities. stdio hosts are auto-detected
92
+ * from the initialize handshake instead.
86
93
  * @returns {McpServer} a server ready to `.connect(transport)`.
87
94
  */
88
95
  function createServer(opts = {}) {
@@ -95,20 +102,27 @@ function createServer(opts = {}) {
95
102
 
96
103
  // Register all tools. `inlineImages` (off by default) is opt-in: only the
97
104
  // remote HTTP host enables it, so stdio clients (Kolbo Code / Desktop / Cursor)
98
- // keep identical text-URL output.
99
- registerGenerateTools(server, client, { inlineImages: !!opts.inlineImages });
100
- registerModelTools(server, client);
101
- registerVoiceTools(server, client);
102
- registerChatTools(server, client);
103
- registerVisualDnaTools(server, client);
104
- registerMoodboardTools(server, client);
105
- registerMediaTools(server, client);
106
- registerPresetTools(server, client);
107
- registerAppBuilderTools(server, client);
108
- registerArtifactTools(server, client);
109
- registerProjectTools(server, client);
110
- registerMusicLibraryTools(server, client);
111
- registerStockLibraryTools(server, client);
105
+ // keep identical text-URL output. `apps` gates interactive widget results
106
+ // (MCP Apps) the same way — see src/apps/index.js.
107
+ const toolOptions = { inlineImages: !!opts.inlineImages, apps: !!opts.apps };
108
+ registerGenerateTools(server, client, toolOptions);
109
+ registerModelTools(server, client, toolOptions);
110
+ registerVoiceTools(server, client, toolOptions);
111
+ registerChatTools(server, client, toolOptions);
112
+ registerVisualDnaTools(server, client, toolOptions);
113
+ registerMoodboardTools(server, client, toolOptions);
114
+ registerMediaTools(server, client, toolOptions);
115
+ registerPresetTools(server, client, toolOptions);
116
+ registerAppBuilderTools(server, client, toolOptions);
117
+ registerArtifactTools(server, client, toolOptions);
118
+ registerProjectTools(server, client, toolOptions);
119
+ registerMusicLibraryTools(server, client, toolOptions);
120
+ registerStockLibraryTools(server, client, toolOptions);
121
+ registerShortsCreatorTools(server, client, toolOptions);
122
+
123
+ // MCP Apps widget resources (ui://kolbo/*). Registering resources is inert
124
+ // for text-only hosts — they never fetch them.
125
+ registerApps(server);
112
126
 
113
127
  return server;
114
128
  }
@@ -291,6 +291,78 @@ async function inlineImageBlocks(urls, opts = {}) {
291
291
  return blocks.filter(Boolean);
292
292
  }
293
293
 
294
+ // ─── MCP Apps generation widget helpers ──────────────────────────────────────
295
+ // When the host renders MCP Apps (claude.ai via the remote connector, Claude
296
+ // Desktop over stdio), generation tools return IMMEDIATELY after submit and the
297
+ // ui://kolbo/generation.html widget takes over: live progress, inline result,
298
+ // action buttons. Text-only hosts never enter this path — their blocking
299
+ // behavior and response bytes are UNCHANGED.
300
+ const { UI, uiResult, appsEnabled, modelIcon } = require('../apps');
301
+
302
+ /**
303
+ * Build the "submitted — widget is live" tool result for a UI host.
304
+ * @param {object} p
305
+ * tool MCP tool name (e.g. 'generate_image')
306
+ * kind 'image' | 'video' | 'audio' | '3d' | 'scenes'
307
+ * gen the submit response ({ generation_id, poll_interval_hint })
308
+ * client KolboClient (for model icon lookup)
309
+ * model, prompt, count, settings, reference_image, estimated_seconds
310
+ * poll_tool widget-side status tool (default 'get_generation_status')
311
+ * status_args args for poll_tool (default { generation_id })
312
+ */
313
+ async function uiGenerating(p) {
314
+ const icon = await modelIcon(p.client, p.model).catch(() => null);
315
+ const structured = {
316
+ phase: 'generating',
317
+ widget: 'generation',
318
+ kind: p.kind,
319
+ tool: p.tool,
320
+ generation_id: p.gen.generation_id,
321
+ poll_tool: p.poll_tool || 'get_generation_status',
322
+ status_args: p.status_args,
323
+ model: p.model || 'Smart Select',
324
+ model_icon: icon,
325
+ prompt: p.prompt,
326
+ count: p.count || 1,
327
+ settings: p.settings || {},
328
+ reference_image: p.reference_image,
329
+ estimated_seconds: p.estimated_seconds,
330
+ };
331
+ const text = JSON.stringify({
332
+ status: 'submitted',
333
+ generation_id: p.gen.generation_id,
334
+ _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 later need the output URLs (e.g. for a follow-up edit), call get_generation_status once with this generation_id.',
335
+ }, null, 2);
336
+ return uiResult(UI.generation, text, structured);
337
+ }
338
+
339
+ /**
340
+ * Wrap an already-completed generation result with the widget (used by tools
341
+ * that stay blocking even on UI hosts, e.g. creative director).
342
+ */
343
+ async function uiCompleted(p, textPayload) {
344
+ const icon = await modelIcon(p.client, p.model).catch(() => null);
345
+ const structured = {
346
+ phase: 'completed',
347
+ widget: 'generation',
348
+ kind: p.kind,
349
+ tool: p.tool,
350
+ model: p.model || 'Smart Select',
351
+ model_icon: icon,
352
+ prompt: p.prompt,
353
+ count: p.count || 1,
354
+ settings: p.settings || {},
355
+ reference_image: p.reference_image,
356
+ urls: p.urls,
357
+ thumbnail_url: p.thumbnail_url,
358
+ title: p.title,
359
+ duration: p.duration,
360
+ scenes: p.scenes,
361
+ credits_used: p.credits_used,
362
+ };
363
+ return uiResult(UI.generation, textPayload, structured);
364
+ }
365
+
294
366
  module.exports = {
295
367
  MAX_FILE_BYTES,
296
368
  VISUAL_DNA_MAX_BYTES,
@@ -303,4 +375,7 @@ module.exports = {
303
375
  creditFields,
304
376
  projectIdField,
305
377
  inlineImageBlocks,
378
+ uiGenerating,
379
+ uiCompleted,
380
+ appsEnabled,
306
381
  };
@@ -6,13 +6,18 @@
6
6
  const { z } = require('zod');
7
7
  const FormData = require('form-data');
8
8
  const { pollUntilDone } = require('../polling');
9
- const { resolveToBuffer, creditFields, projectIdField, inlineImageBlocks } = require('./_shared');
9
+ const { resolveToBuffer, creditFields, projectIdField, inlineImageBlocks, uiGenerating, uiCompleted, appsEnabled } = require('./_shared');
10
+ const { UI, uiResult } = require('../apps');
10
11
 
11
12
  function registerGenerateTools(server, client, options = {}) {
12
13
  // Only enabled by hosts that explicitly opt in (the remote HTTP connector).
13
14
  // stdio hosts (Kolbo Code, Claude Desktop, Cursor) leave this false, so their
14
15
  // tool output is unchanged: a text block with the image URL.
15
16
  const inlineImages = !!options.inlineImages;
17
+ // MCP Apps hosts (claude.ai remote connector, Claude Desktop) get an instant
18
+ // "submitted" response + a live ui://kolbo/generation.html widget that polls
19
+ // get_generation_status itself. Text-only hosts never take this branch.
20
+ const ui = () => appsEnabled(server, options);
16
21
  // ─── generate_image ────────────────────────────────────────
17
22
  server.tool(
18
23
  'generate_image',
@@ -37,6 +42,12 @@ function registerGenerateTools(server, client, options = {}) {
37
42
  reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, preset_id, project_id
38
43
  });
39
44
 
45
+ if (ui()) return uiGenerating({
46
+ tool: 'generate_image', kind: 'image', gen, client, model, prompt,
47
+ count: num_images, settings: { resolution, aspect_ratio },
48
+ reference_image: reference_images?.[0], estimated_seconds: 25
49
+ });
50
+
40
51
  const result = await pollUntilDone(client, gen.generation_id, {
41
52
  interval: (gen.poll_interval_hint || 3) * 1000,
42
53
  timeout: 120000
@@ -81,6 +92,12 @@ function registerGenerateTools(server, client, options = {}) {
81
92
  visual_dna_ids, moodboard_id, enable_web_search, resolution, project_id
82
93
  });
83
94
 
95
+ if (ui()) return uiGenerating({
96
+ tool: 'generate_image_edit', kind: 'image', gen, client, model, prompt,
97
+ count: num_images, settings: { resolution, aspect_ratio },
98
+ reference_image: source_images?.[0], estimated_seconds: 40
99
+ });
100
+
84
101
  // Multi-source compositing or DNA-anchored edits routinely exceed 120s
85
102
  // server-side. Extend the polling window in those cases to avoid forcing
86
103
  // every call into the timeout-and-recover path via get_generation_status.
@@ -146,18 +163,24 @@ function registerGenerateTools(server, client, options = {}) {
146
163
  video_urls: s.video_urls
147
164
  }));
148
165
 
149
- return {
150
- content: [{
151
- type: 'text',
152
- text: JSON.stringify({
153
- ...creditFields(result),
154
- scenes,
155
- total_scenes: result.scenes?.length || 0,
156
- completed_scenes: scenes.length,
157
- _followup_hint: 'Each scene is a separate asset. If the user asks to edit one scene, find that scene by scene_number/title and pass its image_urls[0] (or video_urls[0]) to generate_image_edit / edit_image / edit_video / generate_video_from_video. Do NOT re-run generate_creative_director unless the user explicitly wants a brand-new set.'
158
- }, null, 2)
159
- }]
160
- };
166
+ const cdText = JSON.stringify({
167
+ ...creditFields(result),
168
+ scenes,
169
+ total_scenes: result.scenes?.length || 0,
170
+ completed_scenes: scenes.length,
171
+ _followup_hint: 'Each scene is a separate asset. If the user asks to edit one scene, find that scene by scene_number/title and pass its image_urls[0] (or video_urls[0]) to generate_image_edit / edit_image / edit_video / generate_video_from_video. Do NOT re-run generate_creative_director unless the user explicitly wants a brand-new set.'
172
+ }, null, 2);
173
+
174
+ // Creative Director polls a dedicated status route the widget can't reach
175
+ // through get_generation_status, so it stays blocking on UI hosts too and
176
+ // renders the completed scene gallery.
177
+ if (ui()) return uiCompleted({
178
+ tool: 'generate_creative_director', kind: 'scenes', client, model, prompt,
179
+ settings: { duration, resolution, mode: workflow_type }, scenes,
180
+ credits_used: creditFields(result).credits_used
181
+ }, cdText);
182
+
183
+ return { content: [{ type: 'text', text: cdText }] };
161
184
  }
162
185
  );
163
186
 
@@ -185,6 +208,12 @@ function registerGenerateTools(server, client, options = {}) {
185
208
  prompt, model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, preset_id, project_id
186
209
  });
187
210
 
211
+ if (ui()) return uiGenerating({
212
+ tool: 'generate_video', kind: 'video', gen, client, model, prompt,
213
+ settings: { duration, resolution, aspect_ratio },
214
+ reference_image: reference_images?.[0], estimated_seconds: 120
215
+ });
216
+
188
217
  const result = await pollUntilDone(client, gen.generation_id, {
189
218
  interval: (gen.poll_interval_hint || 8) * 1000,
190
219
  timeout: 300000
@@ -227,6 +256,12 @@ function registerGenerateTools(server, client, options = {}) {
227
256
  image_url, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution, project_id
228
257
  });
229
258
 
259
+ if (ui()) return uiGenerating({
260
+ tool: 'generate_video_from_image', kind: 'video', gen, client, model, prompt,
261
+ settings: { duration, resolution, aspect_ratio },
262
+ reference_image: image_url, estimated_seconds: 120
263
+ });
264
+
230
265
  const result = await pollUntilDone(client, gen.generation_id, {
231
266
  interval: (gen.poll_interval_hint || 8) * 1000,
232
267
  timeout: 300000
@@ -268,6 +303,12 @@ function registerGenerateTools(server, client, options = {}) {
268
303
  prompt, model, style, instrumental, lyrics, vocal_gender, enhance_prompt, preset_id, project_id
269
304
  });
270
305
 
306
+ if (ui()) return uiGenerating({
307
+ tool: 'generate_music', kind: 'audio', gen, client, model: model || 'Suno', prompt,
308
+ settings: { mode: instrumental ? 'instrumental' : (style || undefined) },
309
+ estimated_seconds: 90
310
+ });
311
+
271
312
  const result = await pollUntilDone(client, gen.generation_id, {
272
313
  interval: (gen.poll_interval_hint || 8) * 1000,
273
314
  timeout: 300000
@@ -304,6 +345,11 @@ function registerGenerateTools(server, client, options = {}) {
304
345
  text, voice, model, language, project_id
305
346
  });
306
347
 
348
+ if (ui()) return uiGenerating({
349
+ tool: 'generate_speech', kind: 'audio', gen, client, model, prompt: text,
350
+ settings: { voice: voice || 'Rachel' }, estimated_seconds: 20
351
+ });
352
+
307
353
  const result = await pollUntilDone(client, gen.generation_id, {
308
354
  interval: (gen.poll_interval_hint || 5) * 1000,
309
355
  timeout: 120000
@@ -339,6 +385,11 @@ function registerGenerateTools(server, client, options = {}) {
339
385
  prompt, model, duration, prompt_influence, project_id
340
386
  });
341
387
 
388
+ if (ui()) return uiGenerating({
389
+ tool: 'generate_sound', kind: 'audio', gen, client, model, prompt,
390
+ settings: { duration }, estimated_seconds: 20
391
+ });
392
+
342
393
  const result = await pollUntilDone(client, gen.generation_id, {
343
394
  interval: (gen.poll_interval_hint || 5) * 1000,
344
395
  timeout: 120000
@@ -432,6 +483,12 @@ function registerGenerateTools(server, client, options = {}) {
432
483
  });
433
484
  }
434
485
 
486
+ if (ui()) return uiGenerating({
487
+ tool: 'generate_elements', kind: 'video', gen: startResponse, client, model, prompt,
488
+ settings: { duration, resolution, aspect_ratio },
489
+ reference_image: reference_images?.[0], estimated_seconds: 180
490
+ });
491
+
435
492
  const result = await pollUntilDone(client, startResponse.generation_id, {
436
493
  interval: (startResponse.poll_interval_hint || 8) * 1000,
437
494
  timeout: 600000
@@ -504,6 +561,12 @@ function registerGenerateTools(server, client, options = {}) {
504
561
  });
505
562
  }
506
563
 
564
+ if (ui()) return uiGenerating({
565
+ tool: 'generate_first_last_frame', kind: 'video', gen: startResponse, client, model, prompt,
566
+ settings: { duration, resolution, aspect_ratio },
567
+ reference_image: first_frame_url || undefined, estimated_seconds: 120
568
+ });
569
+
507
570
  const result = await pollUntilDone(client, startResponse.generation_id, {
508
571
  interval: (startResponse.poll_interval_hint || 8) * 1000,
509
572
  timeout: 300000
@@ -607,6 +670,13 @@ function registerGenerateTools(server, client, options = {}) {
607
670
  startResponse = await client.postMultipart('/v1/generate/lipsync', form);
608
671
  }
609
672
 
673
+ if (ui()) return uiGenerating({
674
+ tool: 'generate_lipsync', kind: 'video', gen: startResponse, client, model,
675
+ prompt: text_prompt, settings: { mode: 'lipsync' },
676
+ reference_image: sourceIsUrl && !/\.(mp4|mov|webm|mkv|avi|m4v)(\?|$)/i.test(source) ? source : undefined,
677
+ estimated_seconds: 180
678
+ });
679
+
610
680
  const result = await pollUntilDone(client, startResponse.generation_id, {
611
681
  interval: (startResponse.poll_interval_hint || 8) * 1000,
612
682
  timeout: 600000
@@ -699,6 +769,13 @@ function registerGenerateTools(server, client, options = {}) {
699
769
  startResponse = await client.postMultipart('/v1/generate/video-from-video', form);
700
770
  }
701
771
 
772
+ if (ui()) return uiGenerating({
773
+ tool: 'generate_video_from_video', kind: 'video', gen: startResponse, client, model,
774
+ prompt: prompt || (preset ? `Subtitles preset: ${preset}` : undefined),
775
+ settings: { duration, resolution, aspect_ratio, mode: preset ? 'subtitles' : 'restyle' },
776
+ reference_image: reference_images?.[0], estimated_seconds: 240
777
+ });
778
+
702
779
  const result = await pollUntilDone(client, startResponse.generation_id, {
703
780
  interval: (startResponse.poll_interval_hint || 8) * 1000,
704
781
  timeout: 600000
@@ -742,6 +819,19 @@ function registerGenerateTools(server, client, options = {}) {
742
819
  startResponse = await client.postMultipart('/v1/transcribe', form);
743
820
  }
744
821
 
822
+ if (ui()) {
823
+ return uiResult(UI.transcript, JSON.stringify({
824
+ status: 'submitted',
825
+ generation_id: startResponse.generation_id,
826
+ _widget_note: 'A live Kolbo transcription widget is rendering above — it shows progress, the transcript text, and SRT/TXT download buttons. Tell the user it is transcribing. If you need the transcript text for a follow-up step, call get_generation_status with this generation_id once done.',
827
+ }, null, 2), {
828
+ widget: 'transcript', phase: 'generating',
829
+ generation_id: startResponse.generation_id,
830
+ poll_tool: 'get_generation_status',
831
+ audio_url: isUrl ? source : undefined,
832
+ });
833
+ }
834
+
745
835
  const result = await pollUntilDone(client, startResponse.generation_id, {
746
836
  interval: (startResponse.poll_interval_hint || 5) * 1000,
747
837
  timeout: 1800000 // 30 minutes — long podcasts are a thing
@@ -797,6 +887,12 @@ function registerGenerateTools(server, client, options = {}) {
797
887
  project_id
798
888
  });
799
889
 
890
+ if (ui()) return uiGenerating({
891
+ tool: 'generate_3d', kind: '3d', gen: startResponse, client, model, prompt,
892
+ settings: { mode: mode || (reference_images?.length > 1 ? 'multi' : reference_images?.length === 1 ? 'single' : 'text') },
893
+ reference_image: reference_images?.[0], estimated_seconds: 300
894
+ });
895
+
800
896
  const result = await pollUntilDone(client, startResponse.generation_id, {
801
897
  interval: (startResponse.poll_interval_hint || 8) * 1000,
802
898
  timeout: 900000 // 15 minutes — 3D generation is slow
@@ -840,6 +936,13 @@ function registerGenerateTools(server, client, options = {}) {
840
936
  image_url, operation, model, scale, aspect_ratio, skin_strength, prompt, project_id
841
937
  });
842
938
 
939
+ if (ui()) return uiGenerating({
940
+ tool: 'edit_image', kind: 'image', gen, client, model,
941
+ prompt: prompt || operation,
942
+ settings: { mode: operation, aspect_ratio },
943
+ reference_image: image_url, estimated_seconds: 40
944
+ });
945
+
843
946
  const result = await pollUntilDone(client, gen.generation_id, {
844
947
  interval: (gen.poll_interval_hint || 5) * 1000,
845
948
  timeout: 180000
@@ -890,6 +993,13 @@ function registerGenerateTools(server, client, options = {}) {
890
993
  image_url, audio_url, duration, mode, project_id
891
994
  });
892
995
 
996
+ if (ui()) return uiGenerating({
997
+ tool: 'edit_video', kind: 'video', gen, client, model,
998
+ prompt: prompt || operation,
999
+ settings: { mode: operation, duration, aspect_ratio },
1000
+ reference_image: image_url, estimated_seconds: 180
1001
+ });
1002
+
893
1003
  const result = await pollUntilDone(client, gen.generation_id, {
894
1004
  interval: (gen.poll_interval_hint || 8) * 1000,
895
1005
  timeout: 600000
@@ -6,8 +6,10 @@
6
6
  const { z } = require('zod');
7
7
  const FormData = require('form-data');
8
8
  const { resolveToBuffer } = require('./_shared');
9
+ const { UI, uiResult, appsEnabled } = require('../apps');
9
10
 
10
- function registerMediaTools(server, client) {
11
+ function registerMediaTools(server, client, options = {}) {
12
+ const ui = () => appsEnabled(server, options);
11
13
  // ─── upload_media ──────────────────────────────────────────
12
14
  server.tool(
13
15
  'upload_media',
@@ -72,13 +74,32 @@ function registerMediaTools(server, client) {
72
74
  const qs = params.toString();
73
75
  const result = await client.get(`/v1/media${qs ? '?' + qs : ''}`);
74
76
 
77
+ const media = result.media || [];
78
+ const pagination = result.pagination || null;
79
+ const text = JSON.stringify({ media, pagination }, null, 2);
80
+
81
+ if (ui()) {
82
+ const items = media.slice(0, 24).map((m) => ({
83
+ id: m.id,
84
+ title: m.filename,
85
+ subtitle: m.media_type + (m.size ? ' · ' + Math.round(m.size / 1024) + 'KB' : ''),
86
+ thumbnail: m.media_type === 'image' ? m.url : (m.thumbnail_url || null),
87
+ media_type: m.media_type,
88
+ url: m.url,
89
+ use_hint: 'Use this media library asset in my next step:\nURL: {URL}\n(id: {ID})'
90
+ }));
91
+ return uiResult(UI.mediaGrid, text, {
92
+ widget: 'media-grid',
93
+ title: 'Media Library',
94
+ items,
95
+ total: pagination && pagination.total != null ? pagination.total : media.length
96
+ });
97
+ }
98
+
75
99
  return {
76
100
  content: [{
77
101
  type: 'text',
78
- text: JSON.stringify({
79
- media: result.media || [],
80
- pagination: result.pagination || null
81
- }, null, 2)
102
+ text
82
103
  }]
83
104
  };
84
105
  }
@@ -4,8 +4,72 @@
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
8
 
8
- function registerModelTools(server, client) {
9
+ // type name → human group label for the catalog widget
10
+ const TYPE_GROUPS = {
11
+ text_to_img: 'Image Generation',
12
+ text_to_video: 'Video Generation',
13
+ img_to_video: 'Video Generation',
14
+ music_gen: 'Music',
15
+ text_to_speech: 'Voice',
16
+ image_editing: 'Image Editing',
17
+ video_to_video: 'Video to Video',
18
+ elements: 'Elements',
19
+ };
20
+
21
+ function groupNameFor(m) {
22
+ const t = (Array.isArray(m.types) && m.types[0]) || m.type || '';
23
+ if (TYPE_GROUPS[t]) return TYPE_GROUPS[t];
24
+ if (t === 'three_d' || String(t).startsWith('3d_')) return '3D';
25
+ return 'Other';
26
+ }
27
+
28
+ function modelChips(m) {
29
+ const chips = [];
30
+ if (Array.isArray(m.supported_resolutions) && m.supported_resolutions.length) {
31
+ const highest = [...m.supported_resolutions]
32
+ .sort((a, b) => (parseInt(a, 10) || 0) - (parseInt(b, 10) || 0))
33
+ .pop();
34
+ if (highest) chips.push(String(highest));
35
+ }
36
+ if (Array.isArray(m.supported_durations) && m.supported_durations.length) {
37
+ const ds = [...m.supported_durations].sort((a, b) => a - b);
38
+ chips.push(ds.length > 1 ? `${ds[0]}-${ds[ds.length - 1]}s` : `${ds[0]}s`);
39
+ }
40
+ if (m.supports_visual_dna) chips.push('DNA');
41
+ if (m.new_model || m.newModel) chips.push('NEW');
42
+ return chips.slice(0, 3);
43
+ }
44
+
45
+ // structuredContent for ui://kolbo/catalog.html — see src/apps/widgets/catalog.js
46
+ function buildCatalogStructured(models, type) {
47
+ const groups = [];
48
+ const byName = new Map();
49
+ for (const m of models) {
50
+ const name = groupNameFor(m);
51
+ let g = byName.get(name);
52
+ if (!g) { g = { name, models: [] }; byName.set(name, g); groups.push(g); }
53
+ if (g.models.length >= 12) continue; // cap per group (≤60 total across groups)
54
+ g.models.push({
55
+ name: m.name,
56
+ icon: m.avatar
57
+ ? (/^https?:\/\//i.test(m.avatar) ? m.avatar : `https://app.kolbo.ai/models_icons/${m.avatar}`)
58
+ : null,
59
+ description: String(m.smartSelect_StrengthsSummary || m.summary || m.description || '').slice(0, 90),
60
+ chips: modelChips(m),
61
+ use_hint: `Generate with the "${m.name}" model — ask me what I want to create first.`,
62
+ });
63
+ }
64
+ return {
65
+ widget: 'catalog',
66
+ title: 'Kolbo AI Models' + (type ? ' — ' + type : ''),
67
+ groups,
68
+ };
69
+ }
70
+
71
+ function registerModelTools(server, client, options = {}) {
72
+ const ui = () => appsEnabled(server, options);
9
73
  // ─── list_models ───────────────────────────────────────────
10
74
  server.tool(
11
75
  'list_models',
@@ -23,12 +87,9 @@ function registerModelTools(server, client) {
23
87
  // a request lives here (durations, reference caps, audio/video min/max,
24
88
  // resolution multipliers, supports_* flags, prompt-length limits, etc.).
25
89
  if (format === 'json') {
26
- return {
27
- content: [{
28
- type: 'text',
29
- text: JSON.stringify({ count: result.count, models: result.models }, null, 2)
30
- }]
31
- };
90
+ const text = JSON.stringify({ count: result.count, models: result.models }, null, 2);
91
+ if (ui()) return uiResult(UI.catalog, text, buildCatalogStructured(result.models, type));
92
+ return { content: [{ type: 'text', text }] };
32
93
  }
33
94
 
34
95
  // Split into auto-selectable (has summary) and named-only (no summary)
@@ -197,12 +258,9 @@ function registerModelTools(server, client) {
197
258
  sections.push(`Named-only models (${withoutSummary.length}) — only use if the user explicitly requests by name:\n${withoutSummary.map(formatModel).join('\n')}`);
198
259
  }
199
260
 
200
- return {
201
- content: [{
202
- type: 'text',
203
- text: `Available models (${result.count}):\n\n${sections.join('\n\n')}\n\nUse the "identifier" value as the "model" parameter in generate tools. For programmatic cap validation, re-call with format: "json".`
204
- }]
205
- };
261
+ const text = `Available models (${result.count}):\n\n${sections.join('\n\n')}\n\nUse the "identifier" value as the "model" parameter in generate tools. For programmatic cap validation, re-call with format: "json".`;
262
+ if (ui()) return uiResult(UI.catalog, text, buildCatalogStructured(result.models, type));
263
+ return { content: [{ type: 'text', text }] };
206
264
  }
207
265
  );
208
266
 
@@ -4,8 +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
8
 
8
- function registerMoodboardTools(server, client) {
9
+ function registerMoodboardTools(server, client, options = {}) {
10
+ const ui = () => appsEnabled(server, options);
9
11
  // ─── list_moodboards ───────────────────────────────────────
10
12
  server.tool(
11
13
  'list_moodboards',
@@ -18,15 +20,29 @@ function registerMoodboardTools(server, client) {
18
20
  if (scope && scope !== 'all') params.set('scope', scope);
19
21
  const qs = params.toString();
20
22
  const result = await client.get(`/v1/moodboards${qs ? '?' + qs : ''}`);
21
- return {
22
- content: [{
23
- type: 'text',
24
- text: JSON.stringify({
25
- moodboards: result.moodboards || [],
26
- count: result.count || 0
27
- }, null, 2)
28
- }]
29
- };
23
+ const moodboards = result.moodboards || [];
24
+ const text = JSON.stringify({
25
+ moodboards,
26
+ count: result.count || 0
27
+ }, null, 2);
28
+
29
+ if (ui()) {
30
+ return uiResult(UI.mediaGrid, text, {
31
+ widget: 'media-grid',
32
+ title: 'Moodboards',
33
+ items: moodboards.slice(0, 24).map(mb => ({
34
+ id: mb.id,
35
+ title: mb.name,
36
+ thumbnail: mb.thumbnail || (Array.isArray(mb.image_urls) ? mb.image_urls[0] : undefined),
37
+ media_type: 'image',
38
+ use_hint: 'Apply moodboard "{TITLE}" (moodboard_id: {ID}) to my next generation.'
39
+ })),
40
+ total: result.count || moodboards.length,
41
+ has_more: moodboards.length > 24
42
+ });
43
+ }
44
+
45
+ return { content: [{ type: 'text', text }] };
30
46
  }
31
47
  );
32
48