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