@kolbo/mcp 1.86.0 → 1.86.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 +2 -1
- package/skill/SKILL.md +435 -434
- package/skill/references/models/3d.md +51 -0
- package/skill/references/models/seedance.md +32 -4
- package/skill/references/workflows/cost-and-validation.md +1 -1
- package/src/apps/index.js +750 -737
- package/src/apps/theme.js +13 -0
- package/src/apps/widgets/generation.js +16 -11
- package/src/apps/widgets/mediaGrid.js +14 -1
- package/src/apps/widgets/upload.js +456 -457
- package/src/index.js +6 -1
- package/src/toolAnnotations.js +1 -1
- package/src/tools/_shared.js +183 -32
- package/src/tools/artifacts.js +5 -2
- package/src/tools/generate.js +141 -36
- package/src/tools/media.js +656 -656
- package/src/tools/projects.js +15 -0
- package/src/tools/visual_dna.js +459 -456
- package/src/tools/voices.js +1 -1
package/src/apps/index.js
CHANGED
|
@@ -1,737 +1,750 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* MCP Apps integration (io.modelcontextprotocol/ui) — Kolbo interactive widgets.
|
|
5
|
-
*
|
|
6
|
-
* Registers the ui://kolbo/* HTML resources and provides the helpers tool files
|
|
7
|
-
* use to attach widgets to results. Everything here is ADDITIVE: text-only hosts
|
|
8
|
-
* (Claude Code, Cursor, old clients) ignore `_meta` + `structuredContent` and see
|
|
9
|
-
* exactly the same text responses as before.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
const {
|
|
13
|
-
registerAppResource,
|
|
14
|
-
getUiCapability,
|
|
15
|
-
RESOURCE_MIME_TYPE,
|
|
16
|
-
RESOURCE_URI_META_KEY,
|
|
17
|
-
} = require('@modelcontextprotocol/ext-apps/server');
|
|
18
|
-
|
|
19
|
-
const { generationWidgetHtml } = require('./widgets/generation');
|
|
20
|
-
const { mediaGridWidgetHtml } = require('./widgets/mediaGrid');
|
|
21
|
-
const { catalogWidgetHtml } = require('./widgets/catalog');
|
|
22
|
-
const { transcriptWidgetHtml } = require('./widgets/transcript');
|
|
23
|
-
const { uploadWidgetHtml } = require('./widgets/upload');
|
|
24
|
-
const { listWidgetHtml } = require('./widgets/list');
|
|
25
|
-
const {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
[UI.
|
|
40
|
-
[UI.
|
|
41
|
-
[UI.
|
|
42
|
-
[UI.
|
|
43
|
-
[UI.
|
|
44
|
-
[UI.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
'https://
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
'https://
|
|
77
|
-
'https://
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
'https://
|
|
81
|
-
'https://
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
'https://
|
|
85
|
-
'https://
|
|
86
|
-
'https://
|
|
87
|
-
'https://
|
|
88
|
-
'https://
|
|
89
|
-
'https://
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
'https://api.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
]
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
*
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
*
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
*/
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
// the
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
const
|
|
343
|
-
if (
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
if (
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
return
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
const
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
const
|
|
567
|
-
if (
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
//
|
|
579
|
-
|
|
580
|
-
if (
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
//
|
|
686
|
-
|
|
687
|
-
//
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MCP Apps integration (io.modelcontextprotocol/ui) — Kolbo interactive widgets.
|
|
5
|
+
*
|
|
6
|
+
* Registers the ui://kolbo/* HTML resources and provides the helpers tool files
|
|
7
|
+
* use to attach widgets to results. Everything here is ADDITIVE: text-only hosts
|
|
8
|
+
* (Claude Code, Cursor, old clients) ignore `_meta` + `structuredContent` and see
|
|
9
|
+
* exactly the same text responses as before.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
registerAppResource,
|
|
14
|
+
getUiCapability,
|
|
15
|
+
RESOURCE_MIME_TYPE,
|
|
16
|
+
RESOURCE_URI_META_KEY,
|
|
17
|
+
} = require('@modelcontextprotocol/ext-apps/server');
|
|
18
|
+
|
|
19
|
+
const { generationWidgetHtml } = require('./widgets/generation');
|
|
20
|
+
const { mediaGridWidgetHtml } = require('./widgets/mediaGrid');
|
|
21
|
+
const { catalogWidgetHtml } = require('./widgets/catalog');
|
|
22
|
+
const { transcriptWidgetHtml } = require('./widgets/transcript');
|
|
23
|
+
const { uploadWidgetHtml } = require('./widgets/upload');
|
|
24
|
+
const { listWidgetHtml } = require('./widgets/list');
|
|
25
|
+
const { HOST_MAP } = require('../cdn');
|
|
26
|
+
const { plansWidgetHtml } = require('./widgets/plans');
|
|
27
|
+
|
|
28
|
+
const UI = {
|
|
29
|
+
generation: 'ui://kolbo/generation.html',
|
|
30
|
+
mediaGrid: 'ui://kolbo/media-grid.html',
|
|
31
|
+
catalog: 'ui://kolbo/catalog.html',
|
|
32
|
+
transcript: 'ui://kolbo/transcript.html',
|
|
33
|
+
upload: 'ui://kolbo/upload.html',
|
|
34
|
+
list: 'ui://kolbo/list.html',
|
|
35
|
+
plans: 'ui://kolbo/plans.html',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const WIDGET_BUILDERS = {
|
|
39
|
+
[UI.generation]: generationWidgetHtml,
|
|
40
|
+
[UI.mediaGrid]: mediaGridWidgetHtml,
|
|
41
|
+
[UI.catalog]: catalogWidgetHtml,
|
|
42
|
+
[UI.transcript]: transcriptWidgetHtml,
|
|
43
|
+
[UI.upload]: uploadWidgetHtml,
|
|
44
|
+
[UI.list]: listWidgetHtml,
|
|
45
|
+
[UI.plans]: plansWidgetHtml,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Widgets are pure functions of source — build once per process.
|
|
49
|
+
const htmlCache = new Map();
|
|
50
|
+
function widgetHtml(uri) {
|
|
51
|
+
if (!htmlCache.has(uri)) htmlCache.set(uri, WIDGET_BUILDERS[uri]());
|
|
52
|
+
return htmlCache.get(uri);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Hosts apply a deny-by-default CSP to widget iframes — without this
|
|
56
|
+
// declaration EVERY external asset (generated images/videos on the CDN, model
|
|
57
|
+
// icons, Google Fonts) is silently blocked. resourceDomains maps to
|
|
58
|
+
// img/script/style/font/media-src; connectDomains to connect-src.
|
|
59
|
+
// Every media host cdn.js can hand a widget: each environment's Spaces origin
|
|
60
|
+
// AND the custom domain it gets rewritten to. DERIVED from HOST_MAP, never
|
|
61
|
+
// retyped — the two lists drifted, this one carrying production only, and the
|
|
62
|
+
// result was silent: 565 of 864 production voice documents still store
|
|
63
|
+
// development-bucket thumbnails, cdn.js rewrote them to media-dev.kolbo.ai,
|
|
64
|
+
// and the host's img-src blocked every one. A dev- or staging-pointed MCP had
|
|
65
|
+
// it worse — nothing with a picture in it rendered at all. Add an environment
|
|
66
|
+
// to HOST_MAP and its CSP entry now comes with it.
|
|
67
|
+
const KOLBO_MEDIA_DOMAINS = [...new Set(HOST_MAP.flat())].map((host) => `https://${host}`);
|
|
68
|
+
|
|
69
|
+
const WIDGET_CSP = {
|
|
70
|
+
resourceDomains: [
|
|
71
|
+
// Public hosts owned by Kolbo.
|
|
72
|
+
'https://api.kolbo.ai',
|
|
73
|
+
'https://app.kolbo.ai',
|
|
74
|
+
'https://cdn.kolbo.ai',
|
|
75
|
+
...KOLBO_MEDIA_DOMAINS,
|
|
76
|
+
'https://kolbo-general-media.fra1.digitaloceanspaces.com',
|
|
77
|
+
'https://kolbo-general-media.fra1.cdn.digitaloceanspaces.com',
|
|
78
|
+
|
|
79
|
+
// Fonts used by the shared widget shell.
|
|
80
|
+
'https://fonts.googleapis.com',
|
|
81
|
+
'https://fonts.gstatic.com',
|
|
82
|
+
|
|
83
|
+
// Exact preview hosts returned by the production stock integrations.
|
|
84
|
+
'https://images.pexels.com',
|
|
85
|
+
'https://videos.pexels.com',
|
|
86
|
+
'https://images.unsplash.com',
|
|
87
|
+
'https://plus.unsplash.com',
|
|
88
|
+
'https://pixabay.com',
|
|
89
|
+
'https://cdn.pixabay.com',
|
|
90
|
+
'https://coverr.co',
|
|
91
|
+
'https://cdn.coverr.co',
|
|
92
|
+
'https://freesound.org',
|
|
93
|
+
'https://cdn.freesound.org',
|
|
94
|
+
'https://sketchfab.com',
|
|
95
|
+
'https://media.sketchfab.com',
|
|
96
|
+
'https://cdn.sketchfab.com',
|
|
97
|
+
'https://assets.sketchfab.com',
|
|
98
|
+
'https://sketchfab-prod-media.s3.amazonaws.com',
|
|
99
|
+
|
|
100
|
+
// Voice PREVIEW audio hosts. list_voices ships every voice with a
|
|
101
|
+
// preview_url and the card renders a real <audio> for it, but 150 of the
|
|
102
|
+
// 864 production voices store that preview on a provider host rather than a
|
|
103
|
+
// Kolbo bucket: 138 google voices on storage.googleapis.com and 12 on
|
|
104
|
+
// api.us.elevenlabs.io. media-src blocked both, so every google voice — the
|
|
105
|
+
// entire Hebrew set — rendered a player stuck at 0:00 / 0:00 with no error
|
|
106
|
+
// anywhere. The Spaces-hosted previews come free via HOST_MAP above; these
|
|
107
|
+
// two do not, because they are not ours.
|
|
108
|
+
'https://storage.googleapis.com',
|
|
109
|
+
'https://api.us.elevenlabs.io',
|
|
110
|
+
|
|
111
|
+
// Default SYNCI catalog project. Any production override must be reviewed
|
|
112
|
+
// and added here as an exact hostname before deployment.
|
|
113
|
+
'https://gfbpxdkripkbbrcvoyeh.supabase.co',
|
|
114
|
+
],
|
|
115
|
+
// connect-src — XHR/fetch FROM widget iframes. Used by the upload widget to
|
|
116
|
+
// POST files to /mcp/upload with its short-lived ticket.
|
|
117
|
+
connectDomains: [
|
|
118
|
+
'https://api.kolbo.ai',
|
|
119
|
+
// Since kolbo-api fbed4e7b5 (2026-08-18) the upload ticket points at the
|
|
120
|
+
// non-Cloudflare-proxied `upload-*` twin (500MB cap). Without these hosts
|
|
121
|
+
// the in-chat XHR is CSP-blocked and every claude.ai upload fails silently.
|
|
122
|
+
'https://upload-api.kolbo.ai',
|
|
123
|
+
],
|
|
124
|
+
// Nested iframes. Empty/omitted → frame-src 'none' and the live pricing
|
|
125
|
+
// embed inside the upgrade card is a blank box.
|
|
126
|
+
frameDomains: [
|
|
127
|
+
'https://app.kolbo.ai',
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Register all Kolbo widget resources on an McpServer. */
|
|
132
|
+
function registerApps(server) {
|
|
133
|
+
for (const [uri, name] of [
|
|
134
|
+
[UI.generation, 'Kolbo Generation Widget'],
|
|
135
|
+
[UI.mediaGrid, 'Kolbo Library Widget'],
|
|
136
|
+
[UI.catalog, 'Kolbo Model Catalog Widget'],
|
|
137
|
+
[UI.transcript, 'Kolbo Transcription Widget'],
|
|
138
|
+
[UI.upload, 'Kolbo Upload Widget'],
|
|
139
|
+
[UI.list, 'Kolbo List Widget'],
|
|
140
|
+
[UI.plans, 'Kolbo Plans Widget'],
|
|
141
|
+
]) {
|
|
142
|
+
registerAppResource(
|
|
143
|
+
server, name, uri,
|
|
144
|
+
{ mimeType: RESOURCE_MIME_TYPE, _meta: { csp: WIDGET_CSP, ui: { csp: WIDGET_CSP } } },
|
|
145
|
+
async () => ({
|
|
146
|
+
contents: [{
|
|
147
|
+
uri, mimeType: RESOURCE_MIME_TYPE, text: widgetHtml(uri),
|
|
148
|
+
_meta: { csp: WIDGET_CSP, ui: { csp: WIDGET_CSP } },
|
|
149
|
+
}],
|
|
150
|
+
})
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** `_meta` for a tool RESULT (and optionally for tool registration). */
|
|
156
|
+
function uiMeta(uri) {
|
|
157
|
+
return { [RESOURCE_URI_META_KEY]: uri, ui: { resourceUri: uri } };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Should this server instance produce widget results?
|
|
162
|
+
* - `opts.apps === true` — set by the kolbo-api remote connector (claude.ai),
|
|
163
|
+
* where the stateless transport makes client capabilities unavailable per-call.
|
|
164
|
+
* - stdio hosts (Claude Desktop) — detected from the initialize handshake.
|
|
165
|
+
* - `KOLBO_MCP_APPS=1|0` env — manual override for local testing.
|
|
166
|
+
*/
|
|
167
|
+
function appsEnabled(server, opts = {}) {
|
|
168
|
+
if (process.env.KOLBO_MCP_APPS === '0') return false;
|
|
169
|
+
if (opts.apps === true || process.env.KOLBO_MCP_APPS === '1') return true;
|
|
170
|
+
try {
|
|
171
|
+
const caps = server?.server?.getClientCapabilities?.();
|
|
172
|
+
if (getUiCapability(caps) !== undefined) return true;
|
|
173
|
+
|
|
174
|
+
// Codex Desktop currently mounts MCP App resources from tool `_meta`, but
|
|
175
|
+
// its initialize handshake identifies as `codex-mcp-client` with an empty
|
|
176
|
+
// capabilities object. Without this compatibility path the host mounts a
|
|
177
|
+
// "Preparing" card while the tool takes the blocking/text fallback, so the
|
|
178
|
+
// completed media never reaches the iframe as structuredContent.
|
|
179
|
+
//
|
|
180
|
+
// Keep the desktop-origin check: Codex CLI uses the same client name but is
|
|
181
|
+
// a text surface, where returning immediately would remove the final URLs.
|
|
182
|
+
const info = server?.server?.getClientVersion?.();
|
|
183
|
+
const origin = process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE || '';
|
|
184
|
+
return info?.name === 'codex-mcp-client' && /codex desktop/i.test(origin);
|
|
185
|
+
} catch (_) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Build a widget-carrying tool result. `text` stays the LLM-facing source of
|
|
192
|
+
* truth; `structured` goes to the widget only.
|
|
193
|
+
*/
|
|
194
|
+
function uiResult(uri, text, structured) {
|
|
195
|
+
return {
|
|
196
|
+
content: [{ type: 'text', text }],
|
|
197
|
+
structuredContent: structured,
|
|
198
|
+
_meta: uiMeta(uri),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** List tools always ship structuredContent — Kolbo Code does not advertise
|
|
203
|
+
* MCP Apps, so gating on appsEnabled() left list.html / the generation
|
|
204
|
+
* fallback stuck on Loading with only `{ sessions }` text. */
|
|
205
|
+
function listResult(text, structured) {
|
|
206
|
+
return uiResult(UI.list, text, structured);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* ------------------------------------------------------------------ */
|
|
210
|
+
/* Model icon lookup (name/identifier → absolute avatar URL) */
|
|
211
|
+
/* ------------------------------------------------------------------ */
|
|
212
|
+
|
|
213
|
+
const ICON_TTL_MS = 10 * 60 * 1000;
|
|
214
|
+
const infoCache = new Map(); // apiBase → { at, byKey: Map<lowername, info>, all: info[] }
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Resolve a Model.avatar value to an absolute URL. Avatars are bare filenames
|
|
218
|
+
* (sometimes with spaces). They're mirrored from kolbo-api/assets to the
|
|
219
|
+
* public DO Spaces CDN by kolbo-api scripts/infra/upload-model-icons-cdn.js —
|
|
220
|
+
* the CDN is the ONLY host that reliably loads inside claude.ai's sandboxed
|
|
221
|
+
* widget iframes (api.kolbo.ai sits behind Cloudflare bot rules that block
|
|
222
|
+
* sandbox image requests; app.kolbo.ai is the SPA whose catch-all returns
|
|
223
|
+
* 200 text/html for missing files).
|
|
224
|
+
*/
|
|
225
|
+
const ICON_CDN_BASE = 'https://kolbo-general-media.fra1.cdn.digitaloceanspaces.com/models_icons';
|
|
226
|
+
|
|
227
|
+
function resolveAvatarUrl(avatar) {
|
|
228
|
+
if (!avatar) return null;
|
|
229
|
+
if (/^https?:\/\//i.test(avatar)) {
|
|
230
|
+
try {
|
|
231
|
+
const parsed = new URL(avatar);
|
|
232
|
+
const file = parsed.pathname.match(/\/(?:models_icons|assets)\/([^/]+)$/);
|
|
233
|
+
// api.kolbo.ai / app.kolbo.ai avatars 404 or get bot-blocked inside
|
|
234
|
+
// sandboxed widget iframes. The CDN copy is the same file.
|
|
235
|
+
if (file && /(?:^|\.)kolbo\.ai$|digitaloceanspaces\.com$/i.test(parsed.hostname)) {
|
|
236
|
+
return `${ICON_CDN_BASE}/${file[1]}`;
|
|
237
|
+
}
|
|
238
|
+
} catch (_) { /* keep the original absolute URL */ }
|
|
239
|
+
return avatar;
|
|
240
|
+
}
|
|
241
|
+
return `${ICON_CDN_BASE}/${encodeURIComponent(String(avatar).replace(/^\/+/, ''))}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function modelCatalog(client) {
|
|
245
|
+
const cacheKey = client.apiBase || 'default';
|
|
246
|
+
const hit = infoCache.get(cacheKey);
|
|
247
|
+
if (hit && Date.now() - hit.at < ICON_TTL_MS) return hit;
|
|
248
|
+
const byKey = new Map();
|
|
249
|
+
const all = [];
|
|
250
|
+
try {
|
|
251
|
+
const res = await client.request('GET', '/v1/models');
|
|
252
|
+
const models = res?.models || res?.data?.models || [];
|
|
253
|
+
for (const m of models) {
|
|
254
|
+
if (!m) continue;
|
|
255
|
+
const icon = resolveAvatarUrl(m.avatar, client.apiBase);
|
|
256
|
+
// Real p75 wall-clock estimate mined from production creditUsages —
|
|
257
|
+
// the same source the in-app countdowns use. No estimate → no ETA shown.
|
|
258
|
+
const eta = Number(m.estimatedDurationSeconds || m.estimated_duration_seconds) || null;
|
|
259
|
+
// `name` is the CLEAN display name ("Google TTS"); it is what widgets show.
|
|
260
|
+
// Without it the model chip fell back to whatever raw string the caller or
|
|
261
|
+
// the status endpoint supplied ("google_tts", "fal-ai/bytedance/omnihuman/v1.5").
|
|
262
|
+
// `types` is the catalog `type` array ("text_to_video", "img_to_video", …) —
|
|
263
|
+
// the ONLY thing that tells two same-named variants apart. See canonicalModelId.
|
|
264
|
+
const raw = m.types !== undefined ? m.types : m.type;
|
|
265
|
+
const types = (Array.isArray(raw) ? raw : [raw]).filter(Boolean).map(String);
|
|
266
|
+
const aspects = Array.isArray(m.supported_aspect_ratios)
|
|
267
|
+
? m.supported_aspect_ratios
|
|
268
|
+
: (Array.isArray(m.supportedAspectRatios) ? m.supportedAspectRatios : []);
|
|
269
|
+
const aspectsByType = m.supported_aspect_ratios_by_type || m.supportedAspectRatiosByType || null;
|
|
270
|
+
const info = { icon, eta, id: m.identifier || null, name: m.name || null, types, aspects, aspectsByType };
|
|
271
|
+
all.push(info);
|
|
272
|
+
// Display names collide across variants ("Nano Banana 2" names both the
|
|
273
|
+
// t2i model and its editing sibling) — on collision keep the model with
|
|
274
|
+
// the SHORTEST identifier (the base model), deterministically. This map is
|
|
275
|
+
// for ICONS/ETAs, where the variant doesn't matter; identifier resolution
|
|
276
|
+
// must NOT use it (that is what made "Kling 2.6 Pro" always mean the t2v one).
|
|
277
|
+
const setName = (k) => {
|
|
278
|
+
const prev = byKey.get(k);
|
|
279
|
+
if (!prev || !prev.id || (info.id && info.id.length < prev.id.length)) byKey.set(k, info);
|
|
280
|
+
};
|
|
281
|
+
if (m.name) setName(String(m.name).toLowerCase());
|
|
282
|
+
if (m.identifier) byKey.set(String(m.identifier).toLowerCase(), info);
|
|
283
|
+
}
|
|
284
|
+
} catch (_) {
|
|
285
|
+
/* fail open — widgets fall back to monogram chips, no ETA */
|
|
286
|
+
}
|
|
287
|
+
const entry = { at: Date.now(), byKey, all };
|
|
288
|
+
// Never cache an empty map: the first request in a fresh worker (typical
|
|
289
|
+
// right after a deploy restart) can fail transiently, and caching that
|
|
290
|
+
// failure blanks every model icon for the TTL window.
|
|
291
|
+
if (byKey.size > 0) infoCache.set(cacheKey, entry);
|
|
292
|
+
return entry;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function modelInfoMap(client) {
|
|
296
|
+
return (await modelCatalog(client)).byKey;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Resolve one model's { icon, eta, name }; missing → all null. */
|
|
300
|
+
async function modelInfo(client, modelName) {
|
|
301
|
+
if (!modelName) return { icon: null, eta: null, name: null };
|
|
302
|
+
const catalog = await modelCatalog(client);
|
|
303
|
+
const exact = catalog.byKey.get(String(modelName).toLowerCase());
|
|
304
|
+
if (exact) return exact;
|
|
305
|
+
// Same separator-insensitive / prefix leniency as canonicalModelId — the
|
|
306
|
+
// chip used to miss "gpt-image-2" when the catalog only keyed the editor
|
|
307
|
+
// sibling, and the widget fell back to a first-letter monogram.
|
|
308
|
+
const want = normId(modelName);
|
|
309
|
+
if (!want) return { icon: null, eta: null, name: null };
|
|
310
|
+
const rows = catalog.all || [];
|
|
311
|
+
return rows.find((row) => normId(row.id) === want || normId(row.name) === want)
|
|
312
|
+
|| rows.find((row) => normId(row.id).startsWith(want) || normId(row.name).startsWith(want))
|
|
313
|
+
|| { icon: null, eta: null, name: null };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Snap a requested aspect ratio onto the catalog enum for that model.
|
|
318
|
+
* Every image/video generate_* tool must call this after canonicalModelId —
|
|
319
|
+
* list_models already prints supported_aspect_ratios, but the LLM still
|
|
320
|
+
* invents 21:9 / "widescreen" / "21/9". Fail open only when the catalog is
|
|
321
|
+
* empty or the model is unpublished (hidden ids still reach the API).
|
|
322
|
+
*/
|
|
323
|
+
const ASPECT_ALIASES = {
|
|
324
|
+
square: '1:1',
|
|
325
|
+
landscape: '16:9',
|
|
326
|
+
widescreen: '16:9',
|
|
327
|
+
horizontal: '16:9',
|
|
328
|
+
portrait: '9:16',
|
|
329
|
+
vertical: '9:16',
|
|
330
|
+
story: '9:16',
|
|
331
|
+
stories: '9:16',
|
|
332
|
+
reel: '9:16',
|
|
333
|
+
reels: '9:16',
|
|
334
|
+
ultrawide: '21:9',
|
|
335
|
+
cinema: '21:9',
|
|
336
|
+
cinematic: '21:9',
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
function normalizeAspectRatio(requested) {
|
|
340
|
+
if (requested == null || requested === '') return requested;
|
|
341
|
+
const raw = String(requested).trim();
|
|
342
|
+
const lower = raw.toLowerCase();
|
|
343
|
+
if (lower === 'auto' || lower === 'adaptive') return lower;
|
|
344
|
+
if (ASPECT_ALIASES[lower]) return ASPECT_ALIASES[lower];
|
|
345
|
+
const pair = lower.match(/^(\d+(?:\.\d+)?)\s*[:x×/\-]\s*(\d+(?:\.\d+)?)$/);
|
|
346
|
+
if (!pair) return raw;
|
|
347
|
+
const a = Number(pair[1]);
|
|
348
|
+
const b = Number(pair[2]);
|
|
349
|
+
if (!Number.isFinite(a) || !Number.isFinite(b) || a <= 0 || b <= 0) return raw;
|
|
350
|
+
return Number.isInteger(a) && Number.isInteger(b) ? `${a}:${b}` : `${a}:${b}`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function parseAspectDecimal(ratio) {
|
|
354
|
+
const normalized = normalizeAspectRatio(ratio);
|
|
355
|
+
const parts = String(normalized || '').split(':').map(Number);
|
|
356
|
+
if (parts.length !== 2 || parts.some((n) => !Number.isFinite(n) || n <= 0)) return 1;
|
|
357
|
+
return parts[0] / parts[1];
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function aspectKey(ratio) {
|
|
361
|
+
return String(normalizeAspectRatio(ratio) || '').toLowerCase();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function closestAspectRatio(requested, supported) {
|
|
365
|
+
const normalized = normalizeAspectRatio(requested);
|
|
366
|
+
if (!normalized) return requested;
|
|
367
|
+
if (normalized === 'auto' || normalized === 'adaptive') return normalized;
|
|
368
|
+
if (!Array.isArray(supported) || supported.length === 0) return normalized;
|
|
369
|
+
const exact = supported.find((ratio) => aspectKey(ratio) === aspectKey(normalized));
|
|
370
|
+
if (exact) return exact;
|
|
371
|
+
const concrete = supported.filter((ratio) => {
|
|
372
|
+
const key = aspectKey(ratio);
|
|
373
|
+
return key && key !== 'auto' && key !== 'adaptive';
|
|
374
|
+
});
|
|
375
|
+
if (!concrete.length) return normalized;
|
|
376
|
+
const target = parseAspectDecimal(normalized);
|
|
377
|
+
let best = concrete[0];
|
|
378
|
+
let bestDist = Infinity;
|
|
379
|
+
for (const ratio of concrete) {
|
|
380
|
+
const dist = Math.abs(parseAspectDecimal(ratio) - target);
|
|
381
|
+
if (dist < bestDist) {
|
|
382
|
+
bestDist = dist;
|
|
383
|
+
best = ratio;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return best;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function findCatalogModel(catalog, modelId, type) {
|
|
390
|
+
if (!catalog || !modelId) return null;
|
|
391
|
+
const key = String(modelId).toLowerCase().trim();
|
|
392
|
+
const want = normId(key);
|
|
393
|
+
if (!want) return null;
|
|
394
|
+
const byKey = catalog.byKey && catalog.byKey.get(key);
|
|
395
|
+
if (byKey) return byKey;
|
|
396
|
+
const all = catalog.all || [];
|
|
397
|
+
const types = (Array.isArray(type) ? type : [type]).filter(Boolean);
|
|
398
|
+
const candidates = all.filter((info) =>
|
|
399
|
+
(info.id && (info.id.toLowerCase() === key || normId(info.id) === want))
|
|
400
|
+
|| (info.name && (info.name.toLowerCase() === key || normId(info.name) === want))
|
|
401
|
+
);
|
|
402
|
+
if (!candidates.length) return null;
|
|
403
|
+
if (types.length) {
|
|
404
|
+
const typed = candidates.filter((info) => Array.isArray(info.types) && info.types.some((t) => types.includes(t)));
|
|
405
|
+
if (typed.length) return typed[0];
|
|
406
|
+
}
|
|
407
|
+
return candidates[0];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function supportedAspectsFor(info, type) {
|
|
411
|
+
if (!info) return [];
|
|
412
|
+
const byType = type && info.aspectsByType;
|
|
413
|
+
if (byType && typeof byType === 'object') {
|
|
414
|
+
const typed = Array.isArray(type)
|
|
415
|
+
? type.map((t) => byType[t]).find((arr) => Array.isArray(arr) && arr.length)
|
|
416
|
+
: byType[type];
|
|
417
|
+
if (Array.isArray(typed) && typed.length) return typed;
|
|
418
|
+
}
|
|
419
|
+
return Array.isArray(info.aspects) ? info.aspects : [];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function resolveCatalogAspectRatio(client, modelId, requested, type) {
|
|
423
|
+
if (!requested) return requested;
|
|
424
|
+
const normalized = normalizeAspectRatio(requested);
|
|
425
|
+
if (!modelId) return normalized;
|
|
426
|
+
try {
|
|
427
|
+
const catalog = await modelCatalog(client);
|
|
428
|
+
const info = findCatalogModel(catalog, modelId, type);
|
|
429
|
+
if (!info) return normalized;
|
|
430
|
+
return closestAspectRatio(normalized, supportedAspectsFor(info, type));
|
|
431
|
+
} catch (_) {
|
|
432
|
+
return normalized;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/* ------------------------------------------------------------------ */
|
|
437
|
+
/* Voice lookup (voice_id / display name → { id, name, thumbnail }) */
|
|
438
|
+
/* ------------------------------------------------------------------ */
|
|
439
|
+
|
|
440
|
+
// Same shape and TTL as the model catalog above: the voice catalog is stable,
|
|
441
|
+
// and a per-generation /v1/voices round trip would be paid on every speech card.
|
|
442
|
+
const voiceCache = new Map(); // apiBase → { at, byKey }
|
|
443
|
+
|
|
444
|
+
async function voiceInfoMap(client) {
|
|
445
|
+
const cacheKey = client.apiBase || 'default';
|
|
446
|
+
const hit = voiceCache.get(cacheKey);
|
|
447
|
+
if (hit && Date.now() - hit.at < ICON_TTL_MS) return hit.byKey;
|
|
448
|
+
const byKey = new Map();
|
|
449
|
+
try {
|
|
450
|
+
const res = await client.request('GET', '/v1/voices');
|
|
451
|
+
for (const v of res?.voices || []) {
|
|
452
|
+
if (!v || !v.voice_id) continue;
|
|
453
|
+
// thumbnail/preview come from the catalog record — NEVER templated from
|
|
454
|
+
// the id, so a change to the CDN path scheme cannot silently 404 the card.
|
|
455
|
+
const info = { id: v.voice_id, name: v.name || v.voice_id, thumbnail: v.thumbnail || null };
|
|
456
|
+
byKey.set(String(v.voice_id).toLowerCase(), info);
|
|
457
|
+
// Display names are not unique across locales (the same Gemini voice is
|
|
458
|
+
// catalogued per language). First one wins; the id lookup above is exact.
|
|
459
|
+
const nameKey = String(info.name).toLowerCase();
|
|
460
|
+
if (v.name && !byKey.has(nameKey)) byKey.set(nameKey, info);
|
|
461
|
+
}
|
|
462
|
+
} catch (_) {
|
|
463
|
+
/* fail open — cards fall back to the raw voice string */
|
|
464
|
+
}
|
|
465
|
+
if (byKey.size > 0) voiceCache.set(cacheKey, { at: Date.now(), byKey });
|
|
466
|
+
return byKey;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Resolve a voice id OR display name to { id, name, thumbnail }; null if unknown. */
|
|
470
|
+
async function voiceInfo(client, voice) {
|
|
471
|
+
if (!voice) return null;
|
|
472
|
+
const map = await voiceInfoMap(client);
|
|
473
|
+
return map.get(String(voice).toLowerCase().trim()) || null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Back-compat shim (used by uiCompleted and older call sites). */
|
|
477
|
+
async function modelIcon(client, modelName) {
|
|
478
|
+
return (await modelInfo(client, modelName)).icon;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Separator-insensitive key. Catalog keys carry their own punctuation — the
|
|
482
|
+
// NAME is keyed "minimax h3", the IDENTIFIER "flux-2/flash" — so both sides
|
|
483
|
+
// must be flattened before comparing. Normalising only the input (the old
|
|
484
|
+
// `key.replace(/\s+/g, '-')`) is why "flux-2-flash" never found "flux-2/flash".
|
|
485
|
+
const normId = (s) => String(s || '').toLowerCase().replace(/[\s._/-]+/g, '');
|
|
486
|
+
|
|
487
|
+
// The API maps these to Smart Select itself. They are never typos, so they must
|
|
488
|
+
// never be "corrected" or reported as unknown.
|
|
489
|
+
const AUTO_ALIASES = new Set([
|
|
490
|
+
'auto', 'autoselect', 'smartselect', 'kolbosmartselectrouter', 'default', 'none',
|
|
491
|
+
]);
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Narrow several models that answer to the same string down to one identifier.
|
|
495
|
+
* The CALLING TOOL's catalog type decides: a display name like "Kling 2.6 Pro"
|
|
496
|
+
* names one model PER MODALITY (…/text-to-video and …/image-to-video), and only
|
|
497
|
+
* the caller knows which it wants. No type match (or no type given) → shortest
|
|
498
|
+
* identifier, the same deterministic tiebreak the icon map uses.
|
|
499
|
+
*/
|
|
500
|
+
function pickForType(candidates, types) {
|
|
501
|
+
if (!candidates.length) return null;
|
|
502
|
+
const typed = types.length
|
|
503
|
+
? candidates.filter((i) => i.types.some((t) => types.includes(t)))
|
|
504
|
+
: [];
|
|
505
|
+
const pool = typed.length ? typed : candidates;
|
|
506
|
+
return pool.reduce((a, b) => (b.id.length < a.id.length ? b : a)).id;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Strip modality tokens so a t2v id and its i2v sibling share one family key
|
|
510
|
+
// (grok-imagine-text-to-video ↔ grok-imagine-image-to-video; kling …/text-to-video
|
|
511
|
+
// ↔ …/image-to-video). Version tokens stay (1.5 ≠ 1.0).
|
|
512
|
+
function fam(s) {
|
|
513
|
+
return normId(s).replace(
|
|
514
|
+
/texttovideo|imagetovideo|imgtovideo|texttoimage|imagetoimage|imageediting|imageedit|referencetovideo|videotovideo|videoedit|editvideo|firstlastframe|firstlast/g,
|
|
515
|
+
'',
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function sibling(models, hit, types) {
|
|
520
|
+
if (!hit || !types.length) return hit;
|
|
521
|
+
const row = models.find((i) => i.id === hit);
|
|
522
|
+
if (row && row.types.some((t) => types.includes(t))) return hit;
|
|
523
|
+
const key = fam(hit);
|
|
524
|
+
const sibs = models.filter((i) => fam(i.id) === key && i.types.some((t) => types.includes(t)));
|
|
525
|
+
if (!sibs.length) return hit;
|
|
526
|
+
return sibs.reduce((a, b) => (b.id.length < a.id.length ? b : a)).id;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Lenient model-identifier resolution for LLM-supplied model args.
|
|
531
|
+
* Users say "z-image"; the real identifier is "z-image/turbo" — the backend
|
|
532
|
+
* has no fuzzy matching on generation routes and fails deep in credit
|
|
533
|
+
* reservation. Resolve here: exact name/identifier hit → its identifier;
|
|
534
|
+
* else a separator-insensitive hit ("flux-2-flash" → "flux-2/flash"); else a
|
|
535
|
+
* UNIQUE prefix match ("z-image" → "z-image/turbo").
|
|
536
|
+
*
|
|
537
|
+
* `type` is the calling tool's catalog type (a string, or an array when the
|
|
538
|
+
* tool spans several — lipsync, 3D). It is what makes resolution MODALITY-AWARE:
|
|
539
|
+
* without it, "Kling 2.6 Pro" from generate_video_from_image resolved to
|
|
540
|
+
* kling-video/v2.6/pro/text-to-video (2026-08-10), so the image-to-video
|
|
541
|
+
* pipeline submitted the TEXT-to-video endpoint and billed against it.
|
|
542
|
+
* An explicit t2v identifier on an i2v tool remaps to the unique same-family
|
|
543
|
+
* sibling (grok-imagine-text-to-video → grok-imagine-image-to-video). No
|
|
544
|
+
* sibling → the id is passed through unchanged (MiniMax H3).
|
|
545
|
+
*
|
|
546
|
+
* Still unresolved: throw with the near misses named. The API answers a bad
|
|
547
|
+
* identifier with a bare INVALID_*_MODEL and no hint, which on 2026-08-09 sent
|
|
548
|
+
* an agent guessing "minimax-hailuo-3" (real id: "minimax-h3") and then
|
|
549
|
+
* substituting a far more expensive model. Only throws when the catalog is
|
|
550
|
+
* healthy AND actually offers candidates — otherwise it passes through
|
|
551
|
+
* unchanged, so identifiers the catalog does not publish (hidden models) still
|
|
552
|
+
* reach the API and it stays the source of truth.
|
|
553
|
+
*/
|
|
554
|
+
async function canonicalModelId(client, input, type) {
|
|
555
|
+
if (!input || typeof input !== 'string') return input;
|
|
556
|
+
const key = input.toLowerCase().trim();
|
|
557
|
+
const want = normId(key);
|
|
558
|
+
if (!want || AUTO_ALIASES.has(want)) return input;
|
|
559
|
+
|
|
560
|
+
let all;
|
|
561
|
+
try {
|
|
562
|
+
all = (await modelCatalog(client)).all;
|
|
563
|
+
} catch (_) {
|
|
564
|
+
return input; // fail open — never block a generation on a catalog hiccup
|
|
565
|
+
}
|
|
566
|
+
const models = (all || []).filter((i) => i.id);
|
|
567
|
+
if (!models.length) return input;
|
|
568
|
+
|
|
569
|
+
const types = (Array.isArray(type) ? type : [type]).filter(Boolean);
|
|
570
|
+
const dashed = key.replace(/\s+/g, '-');
|
|
571
|
+
|
|
572
|
+
// 1. exact name / identifier hit
|
|
573
|
+
const exact = sibling(models, pickForType(models.filter((i) => [i.id, i.name].some(
|
|
574
|
+
(k) => k && (k.toLowerCase() === key || k.toLowerCase() === dashed)
|
|
575
|
+
)), types), types);
|
|
576
|
+
if (exact) return exact;
|
|
577
|
+
|
|
578
|
+
// 2. separator-insensitive exact ("flux-2-flash" → "flux-2/flash")
|
|
579
|
+
const loose = sibling(models, pickForType(models.filter((i) => normId(i.id) === want || normId(i.name) === want), types), types);
|
|
580
|
+
if (loose) return loose;
|
|
581
|
+
|
|
582
|
+
// 3. unique prefix ("z-image" → "z-image/turbo") — the modality filter runs
|
|
583
|
+
// FIRST, so a stem shared by a t2v/i2v pair is no longer ambiguous.
|
|
584
|
+
const prefixed = models.filter((i) => normId(i.id).startsWith(want) || normId(i.name).startsWith(want));
|
|
585
|
+
const narrowed = types.length ? prefixed.filter((i) => i.types.some((t) => types.includes(t))) : [];
|
|
586
|
+
const ids = new Set((narrowed.length ? narrowed : prefixed).map((i) => i.id));
|
|
587
|
+
if (ids.size === 1) return [...ids][0];
|
|
588
|
+
|
|
589
|
+
// The general catalog is cached for widget performance, while list_models
|
|
590
|
+
// is intentionally live. On a just-published model, refresh only the typed
|
|
591
|
+
// family before reporting an unknown identifier so a model discovered one
|
|
592
|
+
// moment ago is immediately usable.
|
|
593
|
+
if (types.length && typeof client.get === 'function') {
|
|
594
|
+
try {
|
|
595
|
+
const freshRows = [];
|
|
596
|
+
for (const expectedType of types) {
|
|
597
|
+
const response = await client.get(`/v1/models?type=${encodeURIComponent(expectedType)}`);
|
|
598
|
+
freshRows.push(...(response?.models || response?.data?.models || []));
|
|
599
|
+
}
|
|
600
|
+
const freshMatches = freshRows.filter((row) => {
|
|
601
|
+
const id = row?.identifier;
|
|
602
|
+
const name = row?.name;
|
|
603
|
+
return (id && (id.toLowerCase() === key || normId(id) === want))
|
|
604
|
+
|| (name && (name.toLowerCase() === key || normId(name) === want));
|
|
605
|
+
});
|
|
606
|
+
const freshIds = [...new Set(freshMatches.map((row) => row.identifier).filter(Boolean))];
|
|
607
|
+
if (freshIds.length === 1) return freshIds[0];
|
|
608
|
+
} catch (_) {
|
|
609
|
+
// Keep the existing actionable near-miss error when the refresh fails.
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// 4. unknown — name the near misses instead of dead-ending at the API.
|
|
614
|
+
const stem = normId(key.split(/[\s._/-]+/).filter(Boolean)[0] || key);
|
|
615
|
+
const near = [...new Set(
|
|
616
|
+
models
|
|
617
|
+
.filter((i) => stem && (normId(i.id).startsWith(stem) || normId(i.name).startsWith(stem)))
|
|
618
|
+
.map((i) => (i.name ? `${i.id} (${i.name})` : i.id))
|
|
619
|
+
)].sort().slice(0, 12);
|
|
620
|
+
if (!near.length) return input;
|
|
621
|
+
throw new Error(
|
|
622
|
+
`Unknown model identifier "${input}". Did you mean: ${near.join(', ')}? `
|
|
623
|
+
+ 'Never guess an identifier — call list_models with the matching `type` and `format: "json"` '
|
|
624
|
+
+ 'to get the exact identifiers and caps.'
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Reject a published model that belongs to a different operation family.
|
|
630
|
+
* Hidden/unpublished identifiers still fail open so existing pinned engines
|
|
631
|
+
* remain usable; the API remains authoritative for those.
|
|
632
|
+
*/
|
|
633
|
+
async function assertModelSupportsType(client, modelId, type) {
|
|
634
|
+
if (!modelId || !type) return modelId;
|
|
635
|
+
let all;
|
|
636
|
+
try {
|
|
637
|
+
all = (await modelCatalog(client)).all;
|
|
638
|
+
} catch (_) {
|
|
639
|
+
return modelId;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const row = (all || []).find((item) => item.id === modelId);
|
|
643
|
+
if (!row) return modelId;
|
|
644
|
+
const expected = (Array.isArray(type) ? type : [type]).filter(Boolean);
|
|
645
|
+
if (!expected.length || row.types.some((value) => expected.includes(value))) return modelId;
|
|
646
|
+
|
|
647
|
+
throw new Error(
|
|
648
|
+
`Model "${modelId}" cannot be used for this operation (expected type: ${expected.join(' or ')}). `
|
|
649
|
+
+ `Call list_models with type="${expected[0]}" and pass a concrete identifier it returns.`
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/* ------------------------------------------------------------------ */
|
|
654
|
+
/* Declaration-level widget metadata */
|
|
655
|
+
/* ------------------------------------------------------------------ */
|
|
656
|
+
|
|
657
|
+
// Hosts (claude.ai) decide whether to prepare a widget iframe from the TOOL
|
|
658
|
+
// DECLARATION in tools/list — result-level `_meta` alone is not enough. The
|
|
659
|
+
// legacy server.tool() registration API has no _meta parameter, so we attach
|
|
660
|
+
// it post-registration via the SDK's registered-tool objects (tools/list
|
|
661
|
+
// serves `tool._meta` verbatim; verified against SDK 1.29.0).
|
|
662
|
+
const TOOL_WIDGETS = {
|
|
663
|
+
// generation card
|
|
664
|
+
generate_image: UI.generation,
|
|
665
|
+
generate_image_edit: UI.generation,
|
|
666
|
+
generate_creative_director: UI.generation,
|
|
667
|
+
generate_video: UI.generation,
|
|
668
|
+
generate_video_from_image: UI.generation,
|
|
669
|
+
generate_video_from_video: UI.generation,
|
|
670
|
+
generate_elements: UI.generation,
|
|
671
|
+
generate_first_last_frame: UI.generation,
|
|
672
|
+
generate_lipsync: UI.generation,
|
|
673
|
+
generate_music: UI.generation,
|
|
674
|
+
generate_speech: UI.generation,
|
|
675
|
+
generate_sound: UI.generation,
|
|
676
|
+
generate_3d: UI.generation,
|
|
677
|
+
// Declared explicitly so Apps hosts prepare the card from tools/list rather
|
|
678
|
+
// than inferring it from the `generate_*` name; result-level _meta alone is
|
|
679
|
+
// not enough for hosts that read the declaration (see the note below).
|
|
680
|
+
generate_character_sheet: UI.generation,
|
|
681
|
+
edit_image: UI.generation,
|
|
682
|
+
edit_video: UI.generation,
|
|
683
|
+
// transcript viewer
|
|
684
|
+
transcribe_audio: UI.transcript,
|
|
685
|
+
// model catalog
|
|
686
|
+
list_models: UI.catalog,
|
|
687
|
+
// media grid
|
|
688
|
+
list_media: UI.mediaGrid,
|
|
689
|
+
search_stock_media: UI.mediaGrid,
|
|
690
|
+
search_music_library: UI.mediaGrid,
|
|
691
|
+
browse_music_library: UI.mediaGrid,
|
|
692
|
+
get_stock_collections: UI.mediaGrid,
|
|
693
|
+
list_presets: UI.mediaGrid,
|
|
694
|
+
list_voices: UI.mediaGrid,
|
|
695
|
+
list_visual_dnas: UI.mediaGrid,
|
|
696
|
+
list_moodboards: UI.mediaGrid,
|
|
697
|
+
// NOTE: list_color_palettes' handler has always called uiResult(UI.mediaGrid, ...)
|
|
698
|
+
// (see color_palettes.js) but was missing here — hosts that prepare the widget
|
|
699
|
+
// iframe from the tool DECLARATION (claude.ai reads tools/list, not the result)
|
|
700
|
+
// never saw it as widget-carrying. Result-level _meta alone isn't enough.
|
|
701
|
+
list_color_palettes: UI.mediaGrid,
|
|
702
|
+
// upload widget
|
|
703
|
+
media_upload_widget: UI.upload,
|
|
704
|
+
// generic list widget — flat record lists with no natural thumbnail
|
|
705
|
+
list_projects: UI.list,
|
|
706
|
+
// Must stay list.html — mapping this to generation.html mounts "Kolbo Generation /
|
|
707
|
+
// Preparing" empty cards for every session row.
|
|
708
|
+
list_sessions: UI.list,
|
|
709
|
+
list_session_generations: UI.list,
|
|
710
|
+
list_project_context: UI.list,
|
|
711
|
+
list_agents: UI.list,
|
|
712
|
+
list_docs: UI.list,
|
|
713
|
+
list_media_folders: UI.list,
|
|
714
|
+
list_visual_dna_folders: UI.list,
|
|
715
|
+
show_plans: UI.plans,
|
|
716
|
+
list_project_assets: UI.list,
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
function attachToolWidgetMeta(server) {
|
|
720
|
+
const registered = server && server._registeredTools;
|
|
721
|
+
if (!registered) return;
|
|
722
|
+
for (const [name, uri] of Object.entries(TOOL_WIDGETS)) {
|
|
723
|
+
const tool = registered[name];
|
|
724
|
+
if (!tool) continue;
|
|
725
|
+
tool._meta = { ...(tool._meta || {}), ...uiMeta(uri) };
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
module.exports = {
|
|
730
|
+
UI,
|
|
731
|
+
WIDGET_CSP,
|
|
732
|
+
TOOL_WIDGETS,
|
|
733
|
+
registerApps,
|
|
734
|
+
attachToolWidgetMeta,
|
|
735
|
+
uiMeta,
|
|
736
|
+
uiResult,
|
|
737
|
+
listResult,
|
|
738
|
+
appsEnabled,
|
|
739
|
+
modelIcon,
|
|
740
|
+
modelInfo,
|
|
741
|
+
modelInfoMap,
|
|
742
|
+
voiceInfo,
|
|
743
|
+
canonicalModelId,
|
|
744
|
+
assertModelSupportsType,
|
|
745
|
+
normalizeAspectRatio,
|
|
746
|
+
closestAspectRatio,
|
|
747
|
+
resolveCatalogAspectRatio,
|
|
748
|
+
resolveAvatarUrl,
|
|
749
|
+
widgetHtml, // exported for smoke tests
|
|
750
|
+
};
|