@kolbo/mcp 1.52.1 → 1.53.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.52.1",
3
+ "version": "1.53.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
@@ -21,6 +21,7 @@ const { mediaGridWidgetHtml } = require('./widgets/mediaGrid');
21
21
  const { catalogWidgetHtml } = require('./widgets/catalog');
22
22
  const { transcriptWidgetHtml } = require('./widgets/transcript');
23
23
  const { uploadWidgetHtml } = require('./widgets/upload');
24
+ const { listWidgetHtml } = require('./widgets/list');
24
25
 
25
26
  const UI = {
26
27
  generation: 'ui://kolbo/generation.html',
@@ -28,6 +29,7 @@ const UI = {
28
29
  catalog: 'ui://kolbo/catalog.html',
29
30
  transcript: 'ui://kolbo/transcript.html',
30
31
  upload: 'ui://kolbo/upload.html',
32
+ list: 'ui://kolbo/list.html',
31
33
  };
32
34
 
33
35
  const WIDGET_BUILDERS = {
@@ -36,6 +38,7 @@ const WIDGET_BUILDERS = {
36
38
  [UI.catalog]: catalogWidgetHtml,
37
39
  [UI.transcript]: transcriptWidgetHtml,
38
40
  [UI.upload]: uploadWidgetHtml,
41
+ [UI.list]: listWidgetHtml,
39
42
  };
40
43
 
41
44
  // Widgets are pure functions of source — build once per process.
@@ -93,6 +96,7 @@ function registerApps(server) {
93
96
  [UI.catalog, 'Kolbo Model Catalog Widget'],
94
97
  [UI.transcript, 'Kolbo Transcription Widget'],
95
98
  [UI.upload, 'Kolbo Upload Widget'],
99
+ [UI.list, 'Kolbo List Widget'],
96
100
  ]) {
97
101
  registerAppResource(
98
102
  server, name, uri,
@@ -292,8 +296,21 @@ const TOOL_WIDGETS = {
292
296
  list_visual_dnas: UI.mediaGrid,
293
297
  list_moodboards: UI.mediaGrid,
294
298
  shorts_analyze: UI.mediaGrid,
299
+ // NOTE: list_color_palettes' handler has always called uiResult(UI.mediaGrid, ...)
300
+ // (see color_palettes.js) but was missing here — hosts that prepare the widget
301
+ // iframe from the tool DECLARATION (claude.ai reads tools/list, not the result)
302
+ // never saw it as widget-carrying. Result-level _meta alone isn't enough.
303
+ list_color_palettes: UI.mediaGrid,
295
304
  // upload widget
296
305
  media_upload_widget: UI.upload,
306
+ // generic list widget — flat record lists with no natural thumbnail
307
+ list_projects: UI.list,
308
+ list_sessions: UI.list,
309
+ list_project_context: UI.list,
310
+ list_agents: UI.list,
311
+ list_docs: UI.list,
312
+ list_media_folders: UI.list,
313
+ list_visual_dna_folders: UI.list,
297
314
  };
298
315
 
299
316
  function attachToolWidgetMeta(server) {
package/src/apps/theme.js CHANGED
@@ -242,8 +242,9 @@ html.k-fullscreen .k-actions { flex: none; padding-top: 8px; }
242
242
  .k-cell { position: relative; border-radius: 10px; overflow: hidden; border: 1px solid var(--border);
243
243
  background: var(--surface); cursor: pointer; transition: transform 250ms var(--spring); }
244
244
  .k-cell:hover { transform: translateY(-2px) scale(1.01); }
245
- .k-cell .k-cell-media { aspect-ratio: 1; background: #000; }
245
+ .k-cell .k-cell-media { position: relative; aspect-ratio: 1; background: #000; }
246
246
  .k-cell .k-cell-media img { width: 100%; height: 100%; object-fit: cover; display: block; }
247
+ .k-cell-play { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); }
247
248
  .k-cell .k-cell-label { padding: 6px 8px; font-size: 11px; color: var(--text-muted);
248
249
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
249
250
  .k-cell .k-cell-sub { padding: 0 8px 7px; font-size: 10px; color: var(--text-faint);
@@ -274,7 +275,7 @@ html.k-fullscreen .k-actions { flex: none; padding-top: 8px; }
274
275
  @media (max-width: 520px) {
275
276
  .k-generated-audio { grid-template-columns: 36px minmax(0, 1fr); }
276
277
  .k-generated-audio .k-audio-art { width: 36px; height: 36px; }
277
- .k-generated-audio .k-audio-download { grid-column: 2; justify-self: start; }
278
+ .k-generated-audio .k-btn { grid-column: 2; justify-self: start; }
278
279
  .k-generated-audio .k-audio-player { grid-column: 1 / -1; }
279
280
  }
280
281
  .k-play {
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ const { widgetPage } = require('../html');
4
+
5
+ /**
6
+ * Generic list widget — for tools that return a flat list of named records
7
+ * with no natural thumbnail (projects, sessions, agents, docs, folders,
8
+ * knowledge-base sources). Reuses the exact row shell mediaGrid's audio rows
9
+ * and catalog's model rows already use (`.k-audio-row` / `.k-audio-meta` /
10
+ * `.k-chip` / `.k-btn` — no new CSS needed).
11
+ *
12
+ * structuredContent contract:
13
+ * {
14
+ * widget: 'list',
15
+ * title: 'Your Projects',
16
+ * items: [{
17
+ * id, title, subtitle, // main two lines
18
+ * badge, // small pill, e.g. role/status ("owner", "default", "shared")
19
+ * meta, // small trailing text, e.g. a date or count
20
+ * open_url, // optional — renders an "Open" button (openLink)
21
+ * use_hint // optional — row becomes clickable, sends this on click
22
+ * }],
23
+ * total
24
+ * }
25
+ */
26
+
27
+ const BODY = `
28
+ <div class="k-card">
29
+ <div class="k-head">
30
+ <span class="k-logo" id="logo"></span>
31
+ <span class="k-title" id="title"></span>
32
+ <span class="k-spacer"></span>
33
+ <span class="k-chip" id="count-chip" style="display:none"></span>
34
+ </div>
35
+ <div class="k-body"><div id="stage" class="k-empty">Loading…</div></div>
36
+ <div class="k-footer"><span>Powered by <a href="#" id="kolbo-link">Kolbo.AI</a></span></div>
37
+ </div>
38
+ `;
39
+
40
+ const SCRIPT = `
41
+ el('logo').innerHTML = KOLBO_LOGO + '<span>Kolbo</span>';
42
+ el('kolbo-link').onclick = function (e) { e.preventDefault(); window.kolbo.openLink('https://app.kolbo.ai'); };
43
+ var state = null;
44
+
45
+ function boot(sc) {
46
+ if (!sc || !sc.items) return;
47
+ state = sc;
48
+ el('title').textContent = sc.title || 'List';
49
+ var total = sc.total != null ? sc.total : sc.items.length;
50
+ el('count-chip').style.display = '';
51
+ el('count-chip').textContent = total + (total === 1 ? ' item' : ' items');
52
+ if (!sc.items.length) { el('stage').innerHTML = '<div class="k-empty">Nothing here yet</div>'; return; }
53
+ el('stage').innerHTML = sc.items.slice(0, 40).map(itemHTML).join('');
54
+ el('stage').classList.remove('k-empty');
55
+ wire();
56
+ window.kolbo.notifySize();
57
+ }
58
+
59
+ function itemHTML(item, i) {
60
+ var clickable = !!item.use_hint;
61
+ return '<div class="k-audio-row" data-i="' + i + '"' + (clickable ? ' style="cursor:pointer"' : '') + '>' +
62
+ monogram(item.title || '?') +
63
+ '<div class="k-audio-meta"><div class="k-audio-title">' + esc(item.title || '') + '</div>' +
64
+ (item.subtitle ? '<div class="k-audio-sub">' + esc(item.subtitle) + '</div>' : '') + '</div>' +
65
+ (item.badge ? '<span class="k-chip" style="flex:none">' + esc(item.badge) + '</span>' : '') +
66
+ (item.meta ? '<span style="flex:none;font-size:10.5px;color:var(--text-faint)">' + esc(item.meta) + '</span>' : '') +
67
+ (item.open_url ? '<button class="k-btn" data-open="' + i + '">Open</button>' : '') +
68
+ '</div>';
69
+ }
70
+
71
+ function wire() {
72
+ Array.prototype.forEach.call(document.querySelectorAll('[data-open]'), function (b) {
73
+ b.onclick = function (e) {
74
+ e.stopPropagation();
75
+ window.kolbo.openLink(state.items[+b.getAttribute('data-open')].open_url);
76
+ };
77
+ });
78
+ Array.prototype.forEach.call(document.querySelectorAll('.k-audio-row'), function (row) {
79
+ var item = state.items[+row.getAttribute('data-i')];
80
+ if (!item || !item.use_hint) return;
81
+ row.onclick = function () {
82
+ var msg = item.use_hint.replace('{TITLE}', item.title || '').replace('{ID}', item.id || '');
83
+ window.kolbo.sendMessage(msg);
84
+ };
85
+ });
86
+ }
87
+
88
+ window.kolbo.onToolResult(function (result) {
89
+ var sc = result.structuredContent || structured(result);
90
+ if (sc && sc.items) return boot(sc);
91
+ var card = document.querySelector('.k-card');
92
+ if (card) card.style.display = 'none';
93
+ window.kolbo.notifySize();
94
+ });
95
+ `;
96
+
97
+ function listWidgetHtml() {
98
+ return widgetPage({ title: 'Kolbo List', body: BODY, script: SCRIPT });
99
+ }
100
+
101
+ module.exports = { listWidgetHtml };
@@ -38,7 +38,6 @@ const SCRIPT = `
38
38
  el('logo').innerHTML = KOLBO_LOGO + '<span>Kolbo</span>';
39
39
  el('kolbo-link').onclick = function (e) { e.preventDefault(); window.kolbo.openLink('https://app.kolbo.ai'); };
40
40
  var state = null;
41
- var playing = null;
42
41
 
43
42
  function boot(sc) {
44
43
  if (!sc || !sc.items) return;
@@ -63,12 +62,15 @@ function boot(sc) {
63
62
 
64
63
  function cellHTML(item, i) {
65
64
  var idx = state.items.indexOf(item);
65
+ var isVideo = item.media_type === 'video' && item.url;
66
66
  var media = item.thumbnail
67
67
  ? '<img src="' + esc(item.thumbnail) + '" loading="lazy" alt="">'
68
68
  : '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-faint);font-size:22px">' +
69
69
  kindIcon(item.media_type) + '</div>';
70
70
  return '<div class="k-cell" data-i="' + idx + '">' +
71
- '<div class="k-cell-media">' + media + '</div>' +
71
+ '<div class="k-cell-media">' + media +
72
+ (isVideo ? '<button class="k-play k-cell-play" data-video-play="' + esc(item.url) + '">' + ICONS.play + '</button>' : '') +
73
+ '</div>' +
72
74
  '<div class="k-cell-label">' + esc(item.title || '') + '</div>' +
73
75
  (item.subtitle ? '<div class="k-cell-sub">' + esc(item.subtitle) + '</div>' : '') +
74
76
  '</div>';
@@ -76,13 +78,14 @@ function cellHTML(item, i) {
76
78
 
77
79
  function audioRowHTML(item) {
78
80
  var idx = state.items.indexOf(item);
79
- return '<div class="k-audio-row" data-i="' + idx + '">' +
81
+ var src = item.preview_audio || item.url;
82
+ return '<div class="k-audio-row k-generated-audio" data-i="' + idx + '">' +
80
83
  (item.thumbnail ? '<img class="k-audio-art" src="' + esc(item.thumbnail) + '">' : '<div class="k-audio-art"></div>') +
81
84
  '<div class="k-audio-meta"><div class="k-audio-title">' + esc(item.title || '') + '</div>' +
82
85
  '<div class="k-audio-sub">' + esc(item.subtitle || '') + '</div></div>' +
83
- (item.preview_audio || item.url
84
- ? '<button class="k-play" data-play="' + esc(item.preview_audio || item.url) + '">' + ICONS.play + '</button>' : '') +
85
- '<button class="k-btn" data-use="' + idx + '">Use</button></div>';
86
+ '<button class="k-btn" data-use="' + idx + '">Use</button>' +
87
+ (src ? '<audio class="k-audio-player" src="' + esc(src) + '" controls preload="none" aria-label="Play ' + esc(item.title || '') + '"></audio>' : '') +
88
+ '</div>';
86
89
  }
87
90
 
88
91
  function wire() {
@@ -92,17 +95,25 @@ function wire() {
92
95
  Array.prototype.forEach.call(document.querySelectorAll('[data-use]'), function (b) {
93
96
  b.onclick = function (e) { e.stopPropagation(); useItem(+b.getAttribute('data-use')); };
94
97
  });
95
- Array.prototype.forEach.call(document.querySelectorAll('[data-play]'), function (b) {
98
+ // Native <audio controls> already gives play/pause + a seek bar + duration;
99
+ // just stop clicks on it from bubbling up to the row's "Use" handler, and
100
+ // pause any other preview when a new one starts.
101
+ var players = document.querySelectorAll('.k-audio-player');
102
+ Array.prototype.forEach.call(players, function (player) {
103
+ player.onclick = function (e) { e.stopPropagation(); };
104
+ player.addEventListener('play', function () {
105
+ Array.prototype.forEach.call(players, function (other) { if (other !== player) other.pause(); });
106
+ });
107
+ });
108
+ // Video cells stay a static thumbnail until the play overlay is clicked,
109
+ // then swap in a native <video controls> for an in-place scrubber.
110
+ Array.prototype.forEach.call(document.querySelectorAll('[data-video-play]'), function (b) {
96
111
  b.onclick = function (e) {
97
112
  e.stopPropagation();
98
- var url = b.getAttribute('data-play');
99
- if (playing && playing.src === url && !playing.paused) { playing.pause(); b.innerHTML = ICONS.play; return; }
100
- if (playing) playing.pause();
101
- Array.prototype.forEach.call(document.querySelectorAll('[data-play]'), function (x) { x.innerHTML = ICONS.play; });
102
- playing = new Audio(url);
103
- playing.play();
104
- b.innerHTML = ICONS.pause;
105
- playing.onended = function () { b.innerHTML = ICONS.play; };
113
+ var url = b.getAttribute('data-video-play');
114
+ var holder = b.parentNode;
115
+ holder.innerHTML = '<video src="' + esc(url) + '" controls autoplay playsinline style="width:100%;height:100%;object-fit:cover"></video>';
116
+ holder.querySelector('video').onclick = function (ev) { ev.stopPropagation(); };
106
117
  };
107
118
  });
108
119
  }
@@ -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 registerAgentTools(server, client) {
9
+ function registerAgentTools(server, client, options = {}) {
10
+ const ui = () => appsEnabled(server, options);
9
11
  // ─── list_agents ───────────────────────────────────────────
10
12
  server.tool(
11
13
  'list_agents',
@@ -14,10 +16,28 @@ function registerAgentTools(server, client) {
14
16
  async ({ search }) => {
15
17
  const qs = search ? `?search=${encodeURIComponent(search)}` : '';
16
18
  const result = await client.get(`/v1/agents${qs}`);
17
- return { content: [{ type: 'text', text: JSON.stringify({
18
- agents: result.agents || [],
19
- count: result.count || (result.agents || []).length
20
- }, null, 2) }] };
19
+ const agents = result.agents || [];
20
+ const text = JSON.stringify({
21
+ agents,
22
+ count: result.count || agents.length
23
+ }, null, 2);
24
+
25
+ if (ui()) {
26
+ return uiResult(UI.list, text, {
27
+ widget: 'list',
28
+ title: 'Your Agents',
29
+ items: agents.map(a => ({
30
+ id: a.id,
31
+ title: (a.emoji ? a.emoji + ' ' : '') + a.name,
32
+ subtitle: a.description,
33
+ badge: a.is_global ? 'preset' : null,
34
+ use_hint: 'Use my "{TITLE}" agent (agent_id: {ID}) for this conversation.'
35
+ })),
36
+ total: agents.length
37
+ });
38
+ }
39
+
40
+ return { content: [{ type: 'text', text }] };
21
41
  }
22
42
  );
23
43
 
package/src/tools/docs.js CHANGED
@@ -5,10 +5,12 @@
5
5
 
6
6
  const { z } = require('zod');
7
7
  const { projectIdField } = require('./_shared');
8
+ const { UI, uiResult, appsEnabled } = require('../apps');
8
9
 
9
10
  const CONTENT_GUIDE = 'HTML body content. Use clean semantic HTML the in-app editor understands: <h1>-<h3>, <p>, <ul>/<ol>/<li>, <table>, <blockquote>, <strong>/<em>, <a>. No <script>/<style>/<iframe> (stripped server-side). Write the FULL document yourself — this is where you author the doc.';
10
11
 
11
- function registerDocTools(server, client) {
12
+ function registerDocTools(server, client, options = {}) {
13
+ const ui = () => appsEnabled(server, options);
12
14
  // ─── create_doc ────────────────────────────────────────────
13
15
  server.tool(
14
16
  'create_doc',
@@ -50,7 +52,24 @@ function registerDocTools(server, client) {
50
52
  if (limit) params.set('limit', String(limit));
51
53
  const qs = params.toString();
52
54
  const result = await client.get(`/v1/docs${qs ? '?' + qs : ''}`);
53
- return { content: [{ type: 'text', text: JSON.stringify({ docs: result.docs || [], pagination: result.pagination || null }, null, 2) }] };
55
+ const docs = result.docs || [];
56
+ const text = JSON.stringify({ docs, pagination: result.pagination || null }, null, 2);
57
+
58
+ if (ui()) {
59
+ return uiResult(UI.list, text, {
60
+ widget: 'list',
61
+ title: 'Your AI Docs',
62
+ items: docs.map(d => ({
63
+ id: d.id,
64
+ title: d.title,
65
+ subtitle: (d.updated_at ? String(d.updated_at).slice(0, 10) : '') + (d.is_shared ? ' · shared' : ''),
66
+ open_url: d.share_url || null
67
+ })),
68
+ total: docs.length
69
+ });
70
+ }
71
+
72
+ return { content: [{ type: 'text', text }] };
54
73
  }
55
74
  );
56
75
 
@@ -227,15 +227,25 @@ function registerMediaTools(server, client, options = {}) {
227
227
  {},
228
228
  async () => {
229
229
  const result = await client.get('/v1/media/folders');
230
- return {
231
- content: [{
232
- type: 'text',
233
- text: JSON.stringify({
234
- folders: result.folders || [],
235
- count: result.count || 0
236
- }, null, 2)
237
- }]
238
- };
230
+ const folders = result.folders || [];
231
+ const text = JSON.stringify({ folders, count: result.count || 0 }, null, 2);
232
+
233
+ if (ui()) {
234
+ return uiResult(UI.list, text, {
235
+ widget: 'list',
236
+ title: 'Media Folders',
237
+ items: folders.map(f => ({
238
+ id: f.id,
239
+ title: f.name,
240
+ subtitle: f.description,
241
+ meta: (f.item_count || 0) + (f.item_count === 1 ? ' item' : ' items'),
242
+ use_hint: 'List media in my "{TITLE}" folder (folder_id: {ID}).'
243
+ })),
244
+ total: folders.length
245
+ });
246
+ }
247
+
248
+ return { content: [{ type: 'text', text }] };
239
249
  }
240
250
  );
241
251
 
@@ -5,8 +5,10 @@
5
5
 
6
6
  const { z } = require('zod');
7
7
  const { buildProjectUrl } = require('./_shared');
8
+ const { UI, uiResult, appsEnabled } = require('../apps');
8
9
 
9
- function registerProjectTools(server, client) {
10
+ function registerProjectTools(server, client, options = {}) {
11
+ const ui = () => appsEnabled(server, options);
10
12
  // ─── list_projects ─────────────────────────────────────────
11
13
  server.tool(
12
14
  'list_projects',
@@ -21,16 +23,28 @@ function registerProjectTools(server, client) {
21
23
  is_default: !!p.is_default,
22
24
  open_url: buildProjectUrl(p.id, { is_default: !!p.is_default })
23
25
  }));
24
- return {
25
- content: [{
26
- type: 'text',
27
- text: JSON.stringify({
28
- projects,
29
- count: projects.length,
30
- _hint: 'Pass the chosen `id` as `project_id` on any generate_* tool to drop the generation into that project. Omit project_id to use the project flagged is_default:true. `open_url` opens that project\'s media in the web app (share it with the user).'
31
- }, null, 2)
32
- }]
33
- };
26
+ const text = JSON.stringify({
27
+ projects,
28
+ count: projects.length,
29
+ _hint: 'Pass the chosen `id` as `project_id` on any generate_* tool to drop the generation into that project. Omit project_id to use the project flagged is_default:true. `open_url` opens that project\'s media in the web app (share it with the user).'
30
+ }, null, 2);
31
+
32
+ if (ui()) {
33
+ return uiResult(UI.list, text, {
34
+ widget: 'list',
35
+ title: 'Your Projects',
36
+ items: projects.map(p => ({
37
+ id: p.id,
38
+ title: p.name,
39
+ subtitle: p.role + (p.is_default ? ' · default' : ''),
40
+ open_url: p.open_url,
41
+ use_hint: 'Use my "{TITLE}" project (project_id: {ID}) for what I do next.'
42
+ })),
43
+ total: projects.length
44
+ });
45
+ }
46
+
47
+ return { content: [{ type: 'text', text }] };
34
48
  }
35
49
  );
36
50
 
@@ -131,7 +145,23 @@ function registerProjectTools(server, client) {
131
145
  if (limit) params.set('limit', String(limit));
132
146
  const qs = params.toString();
133
147
  const result = await client.get(`/v1/sessions${qs ? '?' + qs : ''}`);
134
- return { content: [{ type: 'text', text: JSON.stringify({ sessions: result.sessions || [], pagination: result.pagination || null }, null, 2) }] };
148
+ const sessions = result.sessions || [];
149
+ const text = JSON.stringify({ sessions, pagination: result.pagination || null }, null, 2);
150
+
151
+ if (ui()) {
152
+ return uiResult(UI.list, text, {
153
+ widget: 'list',
154
+ title: 'Sessions' + (type ? ' — ' + type : ''),
155
+ items: sessions.map(s => ({
156
+ id: s.session_id,
157
+ title: s.name || s.type,
158
+ subtitle: s.type + (s.updated_at ? ' · ' + String(s.updated_at).slice(0, 10) : '')
159
+ })),
160
+ total: sessions.length
161
+ });
162
+ }
163
+
164
+ return { content: [{ type: 'text', text }] };
135
165
  }
136
166
  );
137
167
 
@@ -162,7 +192,24 @@ function registerProjectTools(server, client) {
162
192
  { project_id: z.string().describe('Project ObjectId.') },
163
193
  async ({ project_id }) => {
164
194
  const result = await client.get(`/v1/projects/${encodeURIComponent(project_id)}/context`);
165
- return { content: [{ type: 'text', text: JSON.stringify({ sources: result.sources || [], count: result.count || 0 }, null, 2) }] };
195
+ const sources = result.sources || [];
196
+ const text = JSON.stringify({ sources, count: result.count || 0 }, null, 2);
197
+
198
+ if (ui()) {
199
+ return uiResult(UI.list, text, {
200
+ widget: 'list',
201
+ title: 'Project Knowledge Base',
202
+ items: sources.map(s => ({
203
+ id: s.file_key,
204
+ title: s.title || s.type,
205
+ subtitle: s.type + ' · ' + s.status,
206
+ open_url: s.url || null
207
+ })),
208
+ total: sources.length
209
+ });
210
+ }
211
+
212
+ return { content: [{ type: 'text', text }] };
166
213
  }
167
214
  );
168
215
 
@@ -210,7 +210,24 @@ function registerVisualDnaTools(server, client, options = {}) {
210
210
  {},
211
211
  async () => {
212
212
  const result = await client.get('/v1/visual-dna/folders');
213
- return { content: [{ type: 'text', text: JSON.stringify({ folders: result.folders || [], count: result.count || 0 }, null, 2) }] };
213
+ const folders = result.folders || [];
214
+ const text = JSON.stringify({ folders, count: result.count || 0 }, null, 2);
215
+
216
+ if (ui()) {
217
+ return uiResult(UI.list, text, {
218
+ widget: 'list',
219
+ title: 'Visual DNA Folders',
220
+ items: folders.map(f => ({
221
+ id: f.id,
222
+ title: f.name,
223
+ meta: f.item_count != null ? (f.item_count + (f.item_count === 1 ? ' DNA' : ' DNAs')) : null,
224
+ use_hint: 'List Visual DNAs in my "{TITLE}" folder (folder_id: {ID}).'
225
+ })),
226
+ total: folders.length
227
+ });
228
+ }
229
+
230
+ return { content: [{ type: 'text', text }] };
214
231
  }
215
232
  );
216
233