@kolbo/mcp 1.58.0 → 1.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.58.0",
3
+ "version": "1.59.0",
4
4
  "description": "Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/apps/index.js CHANGED
@@ -203,7 +203,10 @@ async function modelInfoMap(client) {
203
203
  // Real p75 wall-clock estimate mined from production creditUsages —
204
204
  // the same source the in-app countdowns use. No estimate → no ETA shown.
205
205
  const eta = Number(m.estimatedDurationSeconds || m.estimated_duration_seconds) || null;
206
- const info = { icon, eta, id: m.identifier || null };
206
+ // `name` is the CLEAN display name ("Google TTS"); it is what widgets show.
207
+ // Without it the model chip fell back to whatever raw string the caller or
208
+ // the status endpoint supplied ("google_tts", "fal-ai/bytedance/omnihuman/v1.5").
209
+ const info = { icon, eta, id: m.identifier || null, name: m.name || null };
207
210
  // Display names collide across variants ("Nano Banana 2" names both the
208
211
  // t2i model and its editing sibling) — on collision keep the model with
209
212
  // the SHORTEST identifier (the base model), deterministically.
@@ -224,11 +227,51 @@ async function modelInfoMap(client) {
224
227
  return byKey;
225
228
  }
226
229
 
227
- /** Resolve one model's { icon, eta }; missing → { icon: null, eta: null }. */
230
+ /** Resolve one model's { icon, eta, name }; missing → all null. */
228
231
  async function modelInfo(client, modelName) {
229
- if (!modelName) return { icon: null, eta: null };
232
+ if (!modelName) return { icon: null, eta: null, name: null };
230
233
  const map = await modelInfoMap(client);
231
- return map.get(String(modelName).toLowerCase()) || { icon: null, eta: null };
234
+ return map.get(String(modelName).toLowerCase()) || { icon: null, eta: null, name: null };
235
+ }
236
+
237
+ /* ------------------------------------------------------------------ */
238
+ /* Voice lookup (voice_id / display name → { id, name, thumbnail }) */
239
+ /* ------------------------------------------------------------------ */
240
+
241
+ // Same shape and TTL as the model catalog above: the voice catalog is stable,
242
+ // and a per-generation /v1/voices round trip would be paid on every speech card.
243
+ const voiceCache = new Map(); // apiBase → { at, byKey }
244
+
245
+ async function voiceInfoMap(client) {
246
+ const cacheKey = client.apiBase || 'default';
247
+ const hit = voiceCache.get(cacheKey);
248
+ if (hit && Date.now() - hit.at < ICON_TTL_MS) return hit.byKey;
249
+ const byKey = new Map();
250
+ try {
251
+ const res = await client.request('GET', '/v1/voices');
252
+ for (const v of res?.voices || []) {
253
+ if (!v || !v.voice_id) continue;
254
+ // thumbnail/preview come from the catalog record — NEVER templated from
255
+ // the id, so a change to the CDN path scheme cannot silently 404 the card.
256
+ const info = { id: v.voice_id, name: v.name || v.voice_id, thumbnail: v.thumbnail || null };
257
+ byKey.set(String(v.voice_id).toLowerCase(), info);
258
+ // Display names are not unique across locales (the same Gemini voice is
259
+ // catalogued per language). First one wins; the id lookup above is exact.
260
+ const nameKey = String(info.name).toLowerCase();
261
+ if (v.name && !byKey.has(nameKey)) byKey.set(nameKey, info);
262
+ }
263
+ } catch (_) {
264
+ /* fail open — cards fall back to the raw voice string */
265
+ }
266
+ if (byKey.size > 0) voiceCache.set(cacheKey, { at: Date.now(), byKey });
267
+ return byKey;
268
+ }
269
+
270
+ /** Resolve a voice id OR display name to { id, name, thumbnail }; null if unknown. */
271
+ async function voiceInfo(client, voice) {
272
+ if (!voice) return null;
273
+ const map = await voiceInfoMap(client);
274
+ return map.get(String(voice).toLowerCase().trim()) || null;
232
275
  }
233
276
 
234
277
  /** Back-compat shim (used by uiCompleted and older call sites). */
@@ -341,6 +384,7 @@ module.exports = {
341
384
  modelIcon,
342
385
  modelInfo,
343
386
  modelInfoMap,
387
+ voiceInfo,
344
388
  canonicalModelId,
345
389
  resolveAvatarUrl,
346
390
  widgetHtml, // exported for smoke tests
package/src/apps/theme.js CHANGED
@@ -111,6 +111,7 @@ body {
111
111
  color: #fff; font-size: 9px; font-weight: 700; display: inline-flex;
112
112
  align-items: center; justify-content: center; font-family: 'Inter', sans-serif;
113
113
  }
114
+ .k-chip img.k-voice-thumb { width: 18px; height: 18px; border-radius: 999px; margin-left: -3px; }
114
115
  .k-ref-thumb { width: 26px; height: 26px; border-radius: 6px; object-fit: cover; border: 1px solid var(--border-strong); }
115
116
 
116
117
  /* ---- Generating state ---- */
@@ -105,8 +105,14 @@ function boot(sc) {
105
105
  window.kolbo.notifySize();
106
106
  }
107
107
 
108
+ // model_name / voice_name are the CLEAN catalog names, resolved server-side
109
+ // (src/tools/_shared.js on submit, get_generation_status on completion). The raw
110
+ // ids stay in sc.model / sc.voice for Recreate + model context — never for display.
111
+ function modelLabel(sc) { return sc.model_name || sc.model; }
112
+ function voiceLabel(sc) { return sc.voice_name || sc.voice || (sc.settings || {}).voice; }
113
+
108
114
  function renderChips(sc) {
109
- var h = modelChipHTML(sc.model, sc.model_icon);
115
+ var h = modelChipHTML(modelLabel(sc), sc.model_icon);
110
116
  var s = sc.settings || {};
111
117
  if (sc.kind) h += chip(iconFor(sc.kind) + ' ' + sc.kind);
112
118
  if (s.duration) h += chip(ICONS.clock + ' ' + fmtDur(s.duration));
@@ -120,7 +126,13 @@ function renderChips(sc) {
120
126
  if (s.preset) h += chip('preset');
121
127
  if (s.cinematic) h += chip('cinematic');
122
128
  if (s.audio) h += chip(ICONS.sound + ' audio');
123
- if (s.voice) h += chip(ICONS.mic + ' ' + esc(s.voice));
129
+ var voice = voiceLabel(sc);
130
+ if (voice) {
131
+ var face = sc.voice_thumbnail
132
+ ? '<img class="k-voice-thumb" src="' + esc(sc.voice_thumbnail) + '" alt="" loading="lazy" onerror="this.style.display=\\'none\\'">'
133
+ : ICONS.mic;
134
+ h += chip(face + ' ' + esc(voice));
135
+ }
124
136
  if (s.mode) h += chip(esc(s.mode));
125
137
  if (sc.count > 1) h += chip('×' + sc.count);
126
138
  if (sc.reference_image) h += '<img class="k-ref-thumb" src="' + esc(sc.reference_image) + '" alt="" loading="lazy" title="Reference image" onerror="this.style.display=\\'none\\'">';
@@ -414,6 +426,10 @@ function fillBatchCell(sc, i, g) {
414
426
  function renderResult(sc) {
415
427
  clearTimeout(pollTimer);
416
428
 
429
+ // Repaint the chips: the generating phase only knew what the CALLER asked for
430
+ // (often nothing → "Smart Select"). The completed status carries the model and
431
+ // voice that actually ran, so the finished card must not keep the guess.
432
+ renderChips(sc);
417
433
  setPhaseChip('', false);
418
434
  if (sc.batch && sc.scenes && sc.scenes.length) return renderBatchGrid(sc);
419
435
  if (sc.kind === 'scenes' && sc.scenes && sc.scenes.length) return renderScenes(sc);
@@ -482,7 +498,9 @@ function renderAudio(sc, urls) {
482
498
  (artwork ? '<img class="k-audio-art" src="' + esc(artwork) + '" alt="" loading="lazy">' :
483
499
  '<div class="k-audio-art k-audio-placeholder">' + ICONS.audio + '</div>') +
484
500
  '<div class="k-audio-meta"><div class="k-audio-title">' + esc(title) + '</div>' +
485
- '<div class="k-audio-sub">' + esc(track.model || sc.model || '') +
501
+ // Resolved name first: a per-track model field is the raw id, and every
502
+ // track in one generation came from the same model anyway.
503
+ '<div class="k-audio-sub">' + esc(modelLabel(sc) || track.model || '') +
486
504
  (duration ? ' · ' + fmtDur(duration) : '') + '</div></div>' +
487
505
  '<button class="k-btn k-audio-download" data-audio-download="' + esc(u) +
488
506
  '" aria-label="Download ' + esc(title) + '">' + ICONS.download + ' Download</button>' +
@@ -484,7 +484,22 @@ function buildProjectUrl(projectId, opts = {}) {
484
484
  // ui://kolbo/generation.html widget takes over: live progress, inline result,
485
485
  // action buttons. Text-only hosts never enter this path — their blocking
486
486
  // behavior and response bytes are UNCHANGED.
487
- const { UI, uiResult, appsEnabled, modelIcon } = require('../apps');
487
+ const { UI, uiResult, appsEnabled, modelInfo } = require('../apps');
488
+
489
+ /**
490
+ * Chip identity for a model: the CLEAN display name + its icon, resolved from
491
+ * the same /v1/models catalog `list_models` renders. Callers pass whatever the
492
+ * user/LLM supplied (an identifier like `google_tts`, `fal-ai/…/omnihuman/v1.5`,
493
+ * or a display name) — the card must never show the raw id.
494
+ */
495
+ async function modelChipFields(client, model) {
496
+ const info = await modelInfo(client, model).catch(() => null);
497
+ return {
498
+ model: model || 'Smart Select',
499
+ model_name: (info && info.name) || model || 'Smart Select',
500
+ model_icon: (info && info.icon) || null,
501
+ };
502
+ }
488
503
 
489
504
  /**
490
505
  * Build the "submitted — widget is live" tool result for a UI host.
@@ -494,12 +509,13 @@ const { UI, uiResult, appsEnabled, modelIcon } = require('../apps');
494
509
  * gen the submit response ({ generation_id, poll_interval_hint })
495
510
  * client KolboClient (for model icon lookup)
496
511
  * model, prompt, count, settings, reference_image, estimated_seconds
512
+ * voice resolved voice record { name, thumbnail } (speech only)
497
513
  * poll_tool widget-side status tool (default 'get_generation_status')
498
514
  * status_args args for poll_tool (default { generation_id, wait: true })
499
515
  */
500
516
  async function uiGenerating(p) {
501
517
  // No ETAs anywhere — just a spinner until the poll flips to completed.
502
- const icon = await modelIcon(p.client, p.model).catch(() => null);
518
+ const chip = await modelChipFields(p.client, p.model);
503
519
  const structured = {
504
520
  phase: 'generating',
505
521
  widget: 'generation',
@@ -511,8 +527,8 @@ async function uiGenerating(p) {
511
527
  // wait=true, every open card calls tools/call every few seconds, flooding
512
528
  // the host's global progress/context stream and API rate limits.
513
529
  status_args: p.status_args || { generation_id: p.gen.generation_id, wait: true },
514
- model: p.model || 'Smart Select',
515
- model_icon: icon,
530
+ ...chip,
531
+ ...(p.voice ? { voice_name: p.voice.name, voice_thumbnail: p.voice.thumbnail } : {}),
516
532
  prompt: p.prompt,
517
533
  count: p.count || 1,
518
534
  settings: p.settings || {},
@@ -531,6 +547,7 @@ async function uiGenerating(p) {
531
547
  ? { batch: true, generation_ids: p.generation_ids } : {}),
532
548
  ...(p.failed_submissions && p.failed_submissions.length
533
549
  ? { failed_submissions: p.failed_submissions } : {}),
550
+ ...(p.warning ? { _warning: p.warning } : {}),
534
551
  _widget_note: 'A live Kolbo widget is rendering this generation for the user (progress + final result + action buttons). Tell the user it is generating and the card above will update — do NOT poll in a loop. If you need the output URLs (e.g. for a follow-up edit or a report), call get_generation_status ONCE with wait=true — it blocks until done. Tracking several generations? Pass ALL their ids in generation_ids in that one call.',
535
552
  }, null, 2);
536
553
  return uiResult(UI.generation, text, structured);
@@ -541,14 +558,13 @@ async function uiGenerating(p) {
541
558
  * that stay blocking even on UI hosts, e.g. creative director).
542
559
  */
543
560
  async function uiCompleted(p, textPayload) {
544
- const icon = await modelIcon(p.client, p.model).catch(() => null);
561
+ const chip = await modelChipFields(p.client, p.model);
545
562
  const structured = {
546
563
  phase: 'completed',
547
564
  widget: 'generation',
548
565
  kind: p.kind,
549
566
  tool: p.tool,
550
- model: p.model || 'Smart Select',
551
- model_icon: icon,
567
+ ...chip,
552
568
  prompt: p.prompt,
553
569
  count: p.count || 1,
554
570
  settings: p.settings || {},
@@ -7,7 +7,7 @@ const { z } = require('zod');
7
7
  const FormData = require('form-data');
8
8
  const { pollUntilDone } = require('../polling');
9
9
  const { resolveToBuffer, pollOrTimedOut, creditFields, projectIdField, inlineImageBlocks, buildOpenUrl, uiGenerating, appsEnabled } = require('./_shared');
10
- const { UI, uiResult, canonicalModelId } = require('../apps');
10
+ const { UI, uiResult, canonicalModelId, modelInfo, voiceInfo } = require('../apps');
11
11
 
12
12
  // ─── Cinematic Dimensions schema (shared by generate_image + generate_image_edit) ───
13
13
  // Kolbo's "Cinema mode": eight independent photographic dimensions, each an OPTIONAL
@@ -588,7 +588,7 @@ function registerGenerateTools(server, client, options = {}) {
588
588
  'Convert text to speech using Kolbo AI. Default provider is ElevenLabs. To pick a specific voice by language/gender, call list_voices first and pass the returned voice_id (or a voice display name — both work). Every voice belongs to a provider (ElevenLabs, DeepDub, MiniMax, Google/Gemini, OpenAI, Zonos) and each provider exposes its own expressive/style controls below — the engine ignores any control that does not apply to the chosen voice\'s provider, so it is safe to pass only what you need. Returns the final audio URL when complete.',
589
589
  {
590
590
  text: z.string().describe('The text to convert to speech'),
591
- voice: z.string().optional().describe('Voice ID (from list_voices) or voice display name (e.g., "Rachel", "Adam"). Default: "Rachel"'),
591
+ voice: z.string().optional().describe('Voice ID or display name — MUST come from a `list_voices` result, never constructed. Google/Gemini ids in particular are not validated provider-side: an id that is not in the catalog is silently mapped to another voice (or a default one) and the audio comes back in a voice nobody asked for. Do not pattern-match a locale onto an id you saw for another language. Default: "Rachel"'),
592
592
  model: z.string().optional().describe('Model identifier. Use list_models type="text_to_speech" to see options. Default: eleven_v3'),
593
593
  language: z.string().optional().describe('Language code (e.g., "en-US", "he-IL", "es-ES"). Default: "en-US"'),
594
594
  // ── Expressive style / emotion (provider-specific) ──
@@ -620,6 +620,16 @@ function registerGenerateTools(server, client, options = {}) {
620
620
  },
621
621
  async ({ text, voice, model, language, style_instructions, selected_style, emotion, speaking_speed, similarity_boost, style, use_speaker_boost, variance, tempo, promptBoost, seed, accentControl, voiceTitle, minimax_pitch, minimax_vol, minimax_intensity, minimax_timbre, project_id }) => {
622
622
  model = await canonicalModelId(client, model); // lenient id resolution ("z-image" → "z-image/turbo")
623
+ // Resolve the requested voice against the REAL catalog (cached) so the card
624
+ // can show its display name + portrait instead of a raw id, and so an id
625
+ // that does not exist is reported instead of rendering silently: Google
626
+ // voice ids collapse to their last segment provider-side, so a made-up
627
+ // "en-US-Chirp3-HD-<Name>" either speaks as some other catalog entry's
628
+ // voice or falls back to a default one — with the card naming neither.
629
+ const voiceRecord = await voiceInfo(client, voice).catch(() => null);
630
+ const unknownVoice = voice && !voiceRecord && !/^custom_/i.test(voice)
631
+ ? `Voice "${voice}" is not in the Kolbo voice catalog. Call list_voices and pass a voice_id it returns — an unrecognised id is NOT rejected, it is silently mapped to a different voice, so the audio will not be the voice you named.`
632
+ : null;
623
633
  const gen = await client.post('/v1/generate/speech', {
624
634
  text, voice, model, language,
625
635
  style_instructions, selected_style, emotion, speaking_speed,
@@ -631,7 +641,9 @@ function registerGenerateTools(server, client, options = {}) {
631
641
 
632
642
  if (ui()) return uiGenerating({
633
643
  tool: 'generate_speech', kind: 'audio', gen, client, model, prompt: text,
634
- settings: { voice: voice || 'Rachel', style: selected_style || emotion || style_instructions }
644
+ voice: voiceRecord,
645
+ settings: { voice: voice || 'Rachel', style: selected_style || emotion || style_instructions },
646
+ warning: unknownVoice
635
647
  });
636
648
 
637
649
  const poll = await pollOrTimedOut(client, gen.generation_id, {
@@ -648,7 +660,8 @@ function registerGenerateTools(server, client, options = {}) {
648
660
  ...creditFields(result),
649
661
  urls: result.result.urls,
650
662
  voice: result.result.voice,
651
- duration: result.result.duration
663
+ duration: result.result.duration,
664
+ ...(unknownVoice ? { _warning: unknownVoice } : {})
652
665
  }, null, 2)
653
666
  }]
654
667
  };
@@ -713,6 +726,32 @@ function registerGenerateTools(server, client, options = {}) {
713
726
  }
714
727
  );
715
728
 
729
+ // The status endpoint reports RAW ids — `model: "google_tts"`, `voice:
730
+ // "he-IL-Chirp3-HD-Kore"`. That is the model/voice that ACTUALLY ran (for
731
+ // Smart Select it is the only place the choice surfaces), so the card must
732
+ // show it — but with the clean name + icon everything else uses. Resolved
733
+ // here, off the same cached catalogs `list_models` / `list_voices` serve, so
734
+ // the widget adds no round trip per generation. Written INTO `result` because
735
+ // that is the object the widget merges over its generating-phase state.
736
+ async function addDisplayNames(status) {
737
+ const r = status && status.result;
738
+ if (!r || typeof r !== 'object') return;
739
+ // Always WRITE both keys, even on a catalog miss: the widget merges this
740
+ // over its generating-phase state, so a missing key silently keeps the
741
+ // pre-submit guess ("Smart Select") for a model that is not what ran. An
742
+ // unresolvable id is at least honest.
743
+ if (r.model) {
744
+ const info = await modelInfo(client, r.model).catch(() => null);
745
+ r.model_name = (info && info.name) || r.model;
746
+ r.model_icon = (info && info.icon) || null;
747
+ }
748
+ if (r.voice) {
749
+ const v = await voiceInfo(client, r.voice).catch(() => null);
750
+ r.voice_name = (v && v.name) || r.voice;
751
+ r.voice_thumbnail = (v && v.thumbnail) || null;
752
+ }
753
+ }
754
+
716
755
  // ─── get_generation_status ─────────────────────────────────
717
756
  server.tool(
718
757
  'get_generation_status',
@@ -753,6 +792,7 @@ function registerGenerateTools(server, client, options = {}) {
753
792
  };
754
793
 
755
794
  const results = await Promise.all(ids.map(checkOne));
795
+ await Promise.all(results.map(addDisplayNames));
756
796
 
757
797
  const pending = results.filter(r => r.state !== 'completed' && r.state !== 'failed' && r.state !== 'cancelled');
758
798
  const doneHint = 'ALL generations are in a final state — do NOT poll again. Report the results to the user.';
@@ -19,14 +19,29 @@ function registerVoiceTools(server, client, options = {}) {
19
19
  },
20
20
  async ({ language, gender, provider }) => {
21
21
  const params = new URLSearchParams();
22
- if (language) params.set('language', language);
23
22
  if (gender) params.set('gender', gender);
24
23
  if (provider) params.set('provider', provider);
24
+ const withoutLanguage = `/v1/voices${params.toString() ? '?' + params.toString() : ''}`;
25
25
 
26
- const path = `/v1/voices${params.toString() ? '?' + params.toString() : ''}`;
27
- const result = await client.get(path);
26
+ const qs = new URLSearchParams(params);
27
+ if (language) qs.set('language', language);
28
+ const result = await client.get(`/v1/voices${qs.toString() ? '?' + qs.toString() : ''}`);
28
29
 
29
- const voices = result.voices || [];
30
+ let voices = result.voices || [];
31
+ // The API matches `language` EXACTLY against the voice's language name or
32
+ // its locale code — "hebrew" and "he-IL" both hit, but "he" / "heb" /
33
+ // "Hebrew (Israel)" return nothing. This arg is documented as a partial
34
+ // match, and an empty list reads to the model as "Kolbo has no Google
35
+ // Hebrew voices at all", which is how a whole provider goes missing.
36
+ // Retry ONCE, unfiltered, and match locally — only on the empty path, so
37
+ // the normal call still costs one request.
38
+ if (voices.length === 0 && language) {
39
+ const l = language.toLowerCase();
40
+ const all = await client.get(withoutLanguage);
41
+ voices = (all.voices || []).filter(v =>
42
+ [v.language, v.language_code, v.languageCode]
43
+ .some(x => String(x || '').toLowerCase().includes(l)));
44
+ }
30
45
  if (voices.length === 0) {
31
46
  return {
32
47
  content: [{ type: 'text', text: 'No voices found matching those filters.' }]