@kolbo/mcp 1.110.0 → 1.110.2

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.110.0",
3
+ "version": "1.110.2",
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/html.js CHANGED
@@ -236,9 +236,13 @@ function showPeekItem(time) {
236
236
  var it = peekList[peekIndex];
237
237
  var img = el('peek-img');
238
238
  var vid = el('peek-video');
239
+ var oldNote = el('peek-stage') && el('peek-stage').querySelector('.k-vnote');
240
+ if (oldNote) oldNote.parentNode.removeChild(oldNote);
241
+ vid.removeAttribute('data-unplayable');
239
242
  if (it.kind === 'video') {
240
243
  img.removeAttribute('src');
241
244
  vid.setAttribute('src', it.url);
245
+ guardPlayback(vid);
242
246
  if (time) vid.addEventListener('loadedmetadata', function () { try { vid.currentTime = time; } catch (e) {} }, { once: true });
243
247
  var p = vid.play && vid.play();
244
248
  if (p && p.catch) p.catch(function () {});
@@ -363,6 +367,42 @@ function wireVideoExpand(root) {
363
367
  b.addEventListener('keydown', mark);
364
368
  v.addEventListener('dblclick', function (e) { e.preventDefault(); mark(); b.click(); });
365
369
  v.parentNode.insertBefore(b, v.nextSibling);
370
+ guardPlayback(v);
371
+ });
372
+ }
373
+ // Some outputs cannot be decoded by a browser: 10/12-bit HEVC (Bytedance
374
+ // Upscaler Pro, Seedance 1080p masters) either errors or plays sound over a
375
+ // black frame. Detect both and replace the dead player with a clear note and
376
+ // a Download button instead of leaving the user staring at nothing.
377
+ function guardPlayback(v) {
378
+ if (!v || v.getAttribute('data-guard')) return;
379
+ v.setAttribute('data-guard', '1');
380
+ function fail() {
381
+ if (v.getAttribute('data-unplayable') || !v.parentNode) return;
382
+ v.setAttribute('data-unplayable', '1');
383
+ try { v.pause(); } catch (e) {}
384
+ var url = v.currentSrc || v.getAttribute('src');
385
+ var note = document.createElement('div');
386
+ note.className = 'k-vnote';
387
+ note.innerHTML = '<div class="k-vnote-t"></div><div class="k-vnote-s"></div><button type="button" class="k-btn primary"></button>';
388
+ note.querySelector('.k-vnote-t').textContent = "This video can't play in the browser";
389
+ note.querySelector('.k-vnote-s').textContent = 'It uses a high-quality format (HEVC, 10/12-bit) most browsers do not decode. Download it and open it in a video player.';
390
+ var dl = note.querySelector('button');
391
+ dl.innerHTML = ICONS.download + ' Download video';
392
+ dl.onclick = function (e) { e.stopPropagation(); window.kolbo.openLink(downloadUrl(url)); };
393
+ v.parentNode.insertBefore(note, v.nextSibling);
394
+ if (window.kolbo && window.kolbo.notifySize) window.kolbo.notifySize();
395
+ }
396
+ v.addEventListener('error', function () {
397
+ var code = v.error && v.error.code;
398
+ // 3 = decode failure, 4 = format not supported. Network errors (2) are not a format problem.
399
+ if (code === 3 || code === 4) fail();
400
+ });
401
+ // Sound with no picture: time advances but no video frame was ever decoded.
402
+ v.addEventListener('timeupdate', function () {
403
+ if (v.currentTime < 1.2 || v.getAttribute('data-unplayable')) return;
404
+ var q = v.getVideoPlaybackQuality ? v.getVideoPlaybackQuality() : null;
405
+ if ((q && q.totalVideoFrames === 0) || (v.readyState >= 2 && v.videoWidth === 0)) fail();
366
406
  });
367
407
  }
368
408
  if (window.MutationObserver) {
package/src/apps/index.js CHANGED
@@ -362,7 +362,7 @@ async function modelInfoMap(client) {
362
362
  // model the user actually knows. draft_enhance finalizes a Seedance 2.5 Draft;
363
363
  // its engine id is internal, so the card showed "Smart Select" / a raw id.
364
364
  const DISPLAY_ALIASES = {
365
- 'flux-3-draft-enhance': { base: 'seedance-2-5', name: 'Seedance 2.5 Draft Enhance' },
365
+ 'flux-3-draft-enhance': { base: 'seedance-2-5', name: 'Seedance 2.5 Draft Enhance' },
366
366
  'video-draft-enhance': { base: 'seedance-2-5', name: 'Seedance 2.5 Draft Enhance' },
367
367
  };
368
368
  // Operations whose engine is picked server-side when no model is passed.
@@ -573,11 +573,17 @@ async function modelIcon(client, modelName) {
573
573
  // `key.replace(/\s+/g, '-')`) is why "flux-2-flash" never found "flux-2/flash".
574
574
  const normId = (s) => String(s || '').toLowerCase().replace(/[\s._/-]+/g, '');
575
575
 
576
- // The API maps these to Smart Select itself. They are never typos, so they must
577
- // never be "corrected" or reported as unknown.
576
+ // "Let Kolbo pick" spellings. They are never typos, so they must never be
577
+ // "corrected" or reported as unknown. The API only accepts the exact spellings
578
+ // in API_AUTO_ALIASES; it answers any other explicit model with 400
579
+ // MODEL_NOT_FOUND, so the rest ("Smart Select", "default", "none") are sent
580
+ // as 'auto'.
578
581
  const AUTO_ALIASES = new Set([
579
582
  'auto', 'autoselect', 'smartselect', 'kolbosmartselectrouter', 'default', 'none',
580
583
  ]);
584
+ const API_AUTO_ALIASES = new Set([
585
+ 'smart-select', 'smart_select', 'smartselect', 'auto', 'auto-select', 'kolbo_smart_select_router',
586
+ ]);
581
587
 
582
588
  /**
583
589
  * Narrow several models that answer to the same string down to one identifier.
@@ -649,7 +655,8 @@ async function canonicalModelId(client, input, type) {
649
655
  if (!input || typeof input !== 'string') return input;
650
656
  const key = input.toLowerCase().trim();
651
657
  const want = normId(key);
652
- if (!want || AUTO_ALIASES.has(want)) return input;
658
+ if (!want) return input;
659
+ if (AUTO_ALIASES.has(want)) return API_AUTO_ALIASES.has(key) ? input : 'auto';
653
660
  // Exact native-workflow ids pass through: the near-miss check below would reject an
654
661
  // unpublished or cached-out 'kolbo-*' id as unknown.
655
662
  if (NATIVE_WORKFLOW_MODEL_IDS.has(key)) return input;
package/src/apps/theme.js CHANGED
@@ -222,6 +222,17 @@ html.k-peek-fs .k-card { min-height: 0; backdrop-filter: none; -webkit-backdrop-
222
222
  display: inline-flex; align-items: center; justify-content: center;
223
223
  }
224
224
  .k-vexpand:hover { background: var(--brand); border-color: var(--brand); }
225
+ /* Undecodable video (10/12-bit HEVC): the dead player is replaced by a note. */
226
+ video[data-unplayable] { display: none !important; }
227
+ video[data-unplayable] ~ .k-vexpand { display: none; }
228
+ .k-vnote { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
229
+ width: 100%; min-height: 160px; padding: 18px 16px; border-radius: 10px; text-align: center;
230
+ background: rgba(255, 255, 255, 0.05); }
231
+ .k-vnote-t { font-size: 13px; font-weight: 600; color: var(--text); }
232
+ .k-vnote-s { font-size: 12px; color: var(--text-muted); max-width: 360px; margin-bottom: 4px; }
233
+ .k-peek-stage .k-vnote { max-width: 440px; color: #fff; }
234
+ .k-peek-stage .k-vnote-t { color: #fff; }
235
+ .k-peek-stage .k-vnote-s { color: rgba(255, 255, 255, 0.7); }
225
236
  /* Recent Chromium still draws the native button despite controlslist. */
226
237
  video::-webkit-media-controls-fullscreen-button { display: none !important; }
227
238
  /* ---- Plans / upgrade card ---- */
@@ -393,6 +393,14 @@ function referenceHTML(sc) {
393
393
  + peek + ' onerror="this.style.display=\\'none\\'">';
394
394
  }
395
395
  }
396
+ // Local files the caller passed by path: the iframe cannot load them and they
397
+ // are only uploaded during the run, so name them instead of showing nothing.
398
+ // The finished card carries the uploaded thumbnails and drops these.
399
+ if (sc.phase !== 'completed' && Array.isArray(sc.local_references)) {
400
+ sc.local_references.slice(0, 6).forEach(function (name) {
401
+ h += '<span class="k-chip" title="' + esc(name) + '">' + ICONS.image + ' ' + esc(String(name).slice(0, 24)) + '</span>';
402
+ });
403
+ }
396
404
  return h;
397
405
  }
398
406
  // Which characters/looks are locked into this generation — by face and name,
@@ -1319,11 +1327,16 @@ var PRE_AUDIO_KEYS = ['audio', 'audio_url', 'reference_audio_urls', 'seed_refere
1319
1327
  // this) — rendering the raw identifier the caller passed would put an id on the
1320
1328
  // card, which is never allowed.
1321
1329
  function preRefSc(toolName, a) {
1322
- var img = [], vid = [], aud = [];
1330
+ var img = [], vid = [], aud = [], local = [];
1323
1331
  var take = function (v, bucket) {
1324
1332
  if (typeof v === 'string') {
1325
- // http(s) only — an absolute local path is not loadable from the iframe.
1326
- if (/^https?:/i.test(v) && bucket.indexOf(v) < 0) bucket.push(v);
1333
+ // http(s) only — an absolute local path is not loadable from the iframe,
1334
+ // so it is shown by file name instead (see referenceHTML).
1335
+ if (/^https?:/i.test(v)) { if (bucket.indexOf(v) < 0) bucket.push(v); }
1336
+ else if (/^(?:[a-z]:[\\\\/]|\\/|~[\\\\/]|file:)/i.test(v)) {
1337
+ var name = v.split(/[\\\\/]/).pop() || v;
1338
+ if (local.indexOf(name) < 0) local.push(name);
1339
+ }
1327
1340
  return;
1328
1341
  }
1329
1342
  if (Array.isArray(v)) {
@@ -1343,6 +1356,7 @@ function preRefSc(toolName, a) {
1343
1356
  reference_images: img,
1344
1357
  reference_videos: vid,
1345
1358
  reference_audio: aud,
1359
+ local_references: local,
1346
1360
  settings: {
1347
1361
  duration: a.duration,
1348
1362
  resolution: a.draft === true ? '480p-draft' : a.draft === false ? String(a.resolution || '').replace(/-draft$/i, '') : a.resolution,
@@ -35,14 +35,23 @@ function registerSkillCrud(server, client, { noun, idArg, suffix, plural }) {
35
35
  server.tool(
36
36
  `list_${plural}`,
37
37
  `List the user's custom ${plural} (personal + any platform/org preset ${plural} visible to them). A ${noun} is a reusable, named persona for the chat tool — its \`description\` is the system instruction the model adopts. Use this to resolve a ${noun} NAME the user mentioned into its id, or to show what ${plural} exist. Returns id, name, description, emoji, is_global. Personal ${plural} (is_global:false) are editable/deletable; platform presets are not.${legacyNote}`,
38
- { search: z.string().optional().describe('Optional case-insensitive name filter.') },
39
- async ({ search }) => {
40
- const qs = search ? `?search=${encodeURIComponent(search)}` : '';
41
- const result = await client.get(`/v1/skills${qs}`);
38
+ {
39
+ search: z.string().optional().describe('Optional case-insensitive name filter.'),
40
+ page: z.number().int().min(1).optional().describe('Page number, 1-indexed. Default: 1. The response `pagination.has_more` says whether another page exists.'),
41
+ limit: z.number().int().min(1).max(100).optional().describe('Results per page, max 100. Default: 50.')
42
+ },
43
+ async ({ search, page, limit }) => {
44
+ const params = new URLSearchParams();
45
+ if (search) params.set('search', search);
46
+ if (page) params.set('page', String(page));
47
+ if (limit) params.set('limit', String(limit));
48
+ const qs = params.toString();
49
+ const result = await client.get(`/v1/skills${qs ? '?' + qs : ''}`);
42
50
  const agents = result.agents || [];
43
51
  const text = JSON.stringify({
44
52
  agents,
45
- count: result.count || agents.length
53
+ count: result.count || agents.length,
54
+ ...(result.pagination ? { pagination: result.pagination } : {})
46
55
  }, null, 2);
47
56
 
48
57
  return listResult(text, {
@@ -55,7 +64,9 @@ function registerSkillCrud(server, client, { noun, idArg, suffix, plural }) {
55
64
  badge: a.is_global ? 'preset' : null,
56
65
  use_hint: `Use my "{TITLE}" ${noun} (${idArg}: {ID}) for this conversation.`
57
66
  })),
58
- total: agents.length
67
+ total: agents.length,
68
+ page_tool: `list_${plural}`,
69
+ next_args: result.pagination?.has_more ? { search, page: (page || 1) + 1, limit } : undefined
59
70
  });
60
71
  }
61
72
  );
@@ -66,7 +77,7 @@ function registerSkillCrud(server, client, { noun, idArg, suffix, plural }) {
66
77
  `Create a reusable custom ${noun} (a named persona for the chat tool). The \`description\` IS the ${noun}'s system instruction — write it as the persona + behavior you want ("You are a senior creative director. Turn any brief into a structured shot list…"). Use when the user wants a persistent, reusable assistant ("make me a creative-director ${noun}", "set up a support-triage bot"). For a ONE-OFF persona on a single conversation, pass \`system_prompt\` to chat_send_message instead — no need to create a ${noun}. Plan limits apply (server rejects when the ${noun} cap is reached).${legacyNote}`,
67
78
  {
68
79
  name: z.string().optional().describe(`${noun[0].toUpperCase() + noun.slice(1)} name. If omitted, a name is generated from the description.`),
69
- description: z.string().describe(`The ${noun} persona + instructions (max 2000 chars). This becomes the system prompt the model adopts in every conversation that uses the ${noun}.`),
80
+ description: z.string().describe(`The ${noun} persona + instructions (no API length cap; keep it under 8000 characters if it should stay editable in the Kolbo app). This becomes the system prompt the model adopts in every conversation that uses the ${noun}.`),
70
81
  emoji: z.string().optional().describe('Optional emoji avatar (auto-picked if omitted).'),
71
82
  thumbnail: z.string().optional().describe('Optional thumbnail image URL.')
72
83
  },
@@ -87,7 +98,7 @@ function registerSkillCrud(server, client, { noun, idArg, suffix, plural }) {
87
98
  {
88
99
  [idArg]: z.string().describe(idDesc),
89
100
  name: z.string().optional().describe('New name.'),
90
- description: z.string().optional().describe('New persona/instructions (replaces the old description; max 2000 chars).'),
101
+ description: z.string().optional().describe('New persona/instructions (replaces the old description; no API length cap, keep it under 8000 characters if it should stay editable in the Kolbo app).'),
91
102
  emoji: z.string().optional().describe('New emoji avatar.'),
92
103
  thumbnail: z.string().optional().describe('New thumbnail image URL.')
93
104
  },
@@ -3,12 +3,9 @@
3
3
  *
4
4
  * Audio stem separation — Kolbo's own pipeline, not a passthrough to one vendor.
5
5
  *
6
- * Demucs supplies the masks (its stems sum back to the original at −30 to −37 dB, so nothing
7
- * is resynthesised) and a speech classifier supplies the semantic gate Demucs lacks: a
8
- * "vocals" mask is NOT the same thing as dialogue, because engines, impacts and centred
9
- * ambience land there at full volume. When no words are actually spoken, that mask is folded
10
- * back into Effects rather than handed over mislabeled as Dialogue. That gate is why these
11
- * tools exist as their own surface instead of a `separate` operation on edit_video.
6
+ * The backend runs prompted source separation in sequence (speech, then music, then
7
+ * ambience), each pass working on the previous residual, so the layers stay disjoint.
8
+ * mode="dialogue" stops after the speech pass (Dialogue + M&E only).
12
9
  *
13
10
  * These three routes answer INLINE — there is no job id and nothing to poll. Each call holds
14
11
  * its connection for the whole run, so they carry an explicit long request timeout rather
@@ -57,13 +54,13 @@ function registerAudioStemTools(server, client) {
57
54
  // ─── separate_audio_stems ─────────────────────────────────────
58
55
  server.tool(
59
56
  'separate_audio_stems',
60
- 'Split a soundtrack into separate audio layers: Dialogue, Music, Effects, and a '
57
+ 'Split a soundtrack into separate audio layers: Dialogue, Music, Ambience, Effects, and a '
61
58
  + '"without dialogue" (M&E) track — Kolbo\'s own separation pipeline, built for dubbing, '
62
59
  + 'localisation, podcast cleanup and re-scoring. Use this whenever someone wants to '
63
60
  + 'remove/isolate speech, strip narration, mute the music, get a clean instrumental bed, '
64
- + 'or hand an editor stems. A speech classifier decides whether the vocal mask is really '
65
- + 'dialogue, so an engine roar or centred ambience is not mislabelled as speech — that is '
66
- + 'what makes this better than a plain vocal remover. Costs 5 credits. Runs inline and '
61
+ + 'or hand an editor stems. Speech, music and ambience are pulled out one after another, '
62
+ + 'each from what the previous pass left, so the layers do not overlap. Pass '
63
+ + 'mode="dialogue" when only the voice/background split is needed. Costs 5 credits. Runs inline and '
67
64
  + 'returns the finished layer URLs (usually 20–90 seconds); there is nothing to poll. '
68
65
  + 'Sources up to 15 minutes long — trim longer files first. NOTE: there is no cache on the '
69
66
  + 'URL form, so calling it twice on the same file separates twice and bills twice.',
@@ -73,9 +70,12 @@ function registerAudioStemTools(server, client) {
73
70
  generation_id: z.string().optional()
74
71
  .describe('ID of a Kolbo video generation to separate instead of a URL. Layers are also saved onto that generation, so a repeat call is free.'),
75
72
  project_id: projectIdField,
73
+ mode: z.enum(['full', 'dialogue']).optional()
74
+ .describe('"full" (default): Dialogue, Music, Ambience, Effects and M&E. "dialogue": one speech pass returning only Dialogue and the without-dialogue (M&E) track.'),
76
75
  },
77
- async ({ audio_url, generation_id, project_id }) => {
76
+ async ({ audio_url, generation_id, project_id, mode }) => {
78
77
  const body = buildBody({ audio_url, generation_id, project_id }, 'separate_audio_stems');
78
+ if (mode) body.mode = mode;
79
79
  const result = await client.post('/v1/audio/separate', body, { timeoutMs: STEMS_TIMEOUT_MS });
80
80
  return textResult({
81
81
  ...summarize(result),
@@ -86,11 +86,12 @@ function registerAudioStemTools(server, client) {
86
86
  layer_guide: {
87
87
  dialogue: 'Speech only.',
88
88
  music: 'Score / song bed. Absent when the clip has no music.',
89
+ ambience: 'Room tone / atmosphere. Present in mode="full".',
89
90
  sfx: 'Effects and Foley.',
90
91
  me: 'Everything except dialogue (M&E) — the track to dub over.',
91
92
  original: 'The untouched mix, for reference.',
92
93
  },
93
- next_steps: 'If voices are still faintly audible in the "me" layer, run clean_dialogue_leftovers on its URL. To pull room tone out of the Effects bed, run separate_ambience on it.',
94
+ next_steps: 'If voices are still faintly audible in the "me" layer, run clean_dialogue_leftovers on its URL. Ambience comes back as its own layer in mode="full"; for a dialogue-mode split, run separate_ambience on the "me" layer with source_type="me".',
94
95
  });
95
96
  },
96
97
  );
@@ -101,8 +102,8 @@ function registerAudioStemTools(server, client) {
101
102
  'Strip voices that are still faintly audible in a "without dialogue" (M&E) track produced '
102
103
  + 'by separate_audio_stems. Use it only when the user actually hears speech bleeding '
103
104
  + 'through — it is not part of the normal flow, and it trades fidelity to do its job '
104
- + '(the generative model that removes the leak reconstructs the bed less cleanly than the '
105
- + 'masking model that made it). Typical on dense crowd scenes; unnecessary on clean '
105
+ + '(each cleanup pass re-separates the bed, so the M&E comes back less faithful than the '
106
+ + 'original split; it runs up to 3 passes until the voices are gone). Typical on dense crowd scenes; unnecessary on clean '
106
107
  + 'dialogue. Costs 17 credits, charged even when the track turns out to be already clean '
107
108
  + '(the analysis pass still runs) — the response says which happened. Runs inline.',
108
109
  {
@@ -128,8 +129,9 @@ function registerAudioStemTools(server, client) {
128
129
  server.tool(
129
130
  'separate_ambience',
130
131
  'Pull room tone / atmosphere out of an Effects or Music bed, leaving the hard effects '
131
- + 'behind — a fourth lane on top of separate_audio_stems, for when someone wants the '
132
- + 'ambience of a location on its own, or wants it gone. Run separate_audio_stems first '
132
+ + 'behind. separate_audio_stems in its default "full" mode already returns an Ambience '
133
+ + 'layer, so this is only needed for a dialogue-mode split, an older split, or a bed that '
134
+ + 'still carries room tone. Run separate_audio_stems first '
133
135
  + 'and pass the resulting "sfx" (or "music") layer URL. Costs 17 credits, charged even '
134
136
  + 'when the bed turns out to have no separable ambience — the response reports '
135
137
  + 'skipped: true in that case. Runs inline.',
package/src/tools/chat.js CHANGED
@@ -26,11 +26,11 @@ function registerChatTools(server, client) {
26
26
  project_id: projectIdField
27
27
  },
28
28
  async ({ message, model, session_id, system_prompt, web_search, deep_think, thinking_level, routing_mode, enhance_prompt = false, media_urls, project_id }) => {
29
- // Every generate_* tool resolves its model this way; chat was the one
30
- // `model` arg that went straight to the API, which has no fuzzy matching.
31
- // So the display names list_models hands back ("Claude Fable 5") came
32
- // back as a bare `Model not found: Claude Fable 5 [MODEL_NOT_FOUND]` —
33
- // discovery had no path to use.
29
+ // Every generate_* tool resolves its model this way. The API answers an
30
+ // explicit model it cannot match with 400 MODEL_NOT_FOUND (plus "did you
31
+ // mean" suggestions) instead of silently falling back to Smart Select, so
32
+ // resolve display names list_models hands back ("Claude Fable 5") to their
33
+ // catalog identifier here, scoped to the text catalog, before sending.
34
34
  model = await canonicalModelId(client, model, 'text'); // lenient id resolution ("Grok 4.5" → its identifier)
35
35
 
36
36
  const gen = await client.post('/v1/chat', {
package/src/tools/docs.js CHANGED
@@ -7,7 +7,7 @@ const { z } = require('zod');
7
7
  const { projectIdField } = require('./_shared');
8
8
  const { listResult } = require('../apps');
9
9
 
10
- const CONTENT_GUIDE = 'HTML body content. Use clean semantic HTML the in-app editor understands: <h1>-<h3>, <p>, <ul>/<ol>/<li>, <table>, <blockquote>, <strong>/<em>, <a>. No <script>/<style>/<iframe> (stripped server-side). Write the FULL document yourself — this is where you author the doc.';
10
+ const CONTENT_GUIDE = 'HTML body content. Use clean semantic HTML the in-app editor understands: <h1>-<h3>, <p>, <ul>/<ol>/<li>, <table>, <blockquote>, <strong>/<em>, <a>, <img>, <video>/<audio> (http(s) src, e.g. Kolbo CDN URLs from list_media), and <iframe> embeds from YouTube, Vimeo, Spotify, SoundCloud, CodePen, CodeSandbox, Google Maps, Figma or Loom (https only; other iframe hosts are stripped). No <script>/<style> (stripped server-side). Write the FULL document yourself — this is where you author the doc.';
11
11
 
12
12
  function registerDocTools(server, client) {
13
13
  // ─── create_doc ────────────────────────────────────────────
@@ -90,12 +90,14 @@ function registerDocTools(server, client) {
90
90
  {
91
91
  doc_id: z.string().describe('The doc ObjectId to update.'),
92
92
  title: z.string().optional().describe('New title. Omit to keep the current one.'),
93
- content: z.string().optional().describe('Full replacement ' + CONTENT_GUIDE)
93
+ content: z.string().optional().describe('Full replacement ' + CONTENT_GUIDE),
94
+ destructive_intent: z.enum(['replace-document', 'clear-document']).optional().describe('Only when the update intentionally removes most of the document. The server rejects with 409 DESTRUCTIVE_DOCUMENT_REPLACEMENT when new content drops most of the text or every table/image/video, and with 409 DOCUMENT_CLEAR_REQUIRES_INTENT when it empties a populated doc. "replace-document" = the user asked for a rewrite/replacement; "clear-document" = the user asked to empty the doc (send empty content). Never pass it to get past a 409 the user did not ask for — re-read with get_doc and merge instead.')
94
95
  },
95
- async ({ doc_id, title, content }) => {
96
+ async ({ doc_id, title, content, destructive_intent }) => {
96
97
  const body = {};
97
98
  if (title !== undefined) body.title = title;
98
99
  if (content !== undefined) body.content = content;
100
+ if (destructive_intent !== undefined) body.destructive_intent = { kind: destructive_intent };
99
101
  const result = await client.put(`/v1/docs/${encodeURIComponent(doc_id)}`, body);
100
102
  return { content: [{ type: 'text', text: JSON.stringify(result.doc, null, 2) }] };
101
103
  }
@@ -486,7 +486,7 @@ function registerGenerateTools(server, client, options = {}) {
486
486
  aspect_ratio: z.string().optional().describe(aspectRatioDescribe('1:1')),
487
487
  workflow_type: z.string().optional().describe('"image" (default) or "video"'),
488
488
  duration: z.number().optional().describe('Duration in seconds per scene (video mode only). Must be a value in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration`. E.g., 5 or 10.'),
489
- enhance_prompt: z.boolean().optional().describe('Enhance prompts per scene. Default: false — only pass true if the user explicitly asks to enhance/improve the prompts.'),
489
+ enhance_prompt: z.boolean().optional().describe('Ignored by the server: Creative Director always writes and enhances each scene prompt itself, so true and false behave the same. Kept for backward compatibility.'),
490
490
  reference_images: z.array(z.string()).optional().describe('Array of reference images (URLs or absolute local paths) to guide style/composition of every scene. **Cap: pass at most `max_reference_images` URLs from list_models for the chosen model.**'),
491
491
  visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply consistently across every scene. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model.** This is the ideal way to keep a character or product looking the same in all scenes of a campaign.'),
492
492
  moodboard_id: z.string().optional().describe('A single moodboard ID whose master_prompt and style_guide should shape every scene.'),
@@ -70,7 +70,7 @@ function registerProjectTools(server, client) {
70
70
  // ─── move_session ──────────────────────────────────────────
71
71
  server.tool(
72
72
  'move_session',
73
- 'Move ONE session — and ALL of its generations and media library items — to another project. Works for any session type: generation sessions (the `session_id` returned by generate_* tools), chat conversations, transcription sessions, etc. Use this when work landed in the wrong project (e.g. the default "API Generations" bucket) and the user wants it in a named project — moving is always better than regenerating. For SEVERAL sessions use `bulk_move_sessions` instead: one call, up to 100 sessions, and it reports per-session failures. Caller needs edit/full/owner permission on BOTH the source and target projects (a shared-project member can move a teammate\'s session). Resolve the target project id with `list_projects` first.',
73
+ 'Move ONE session — and ALL of its generations and media library items — to another project. Works for image, image_edit, video, video_from_image, elements, first_last_frame, lipsync, video_from_video, global_video_edit, music, speech, sound and chat sessions (the `session_id` returned by generate_* tools, or a chat conversation). Creative Director, transcription, global_image_edit and shorts sessions cannot be moved through the API and return SESSION_TYPE_NOT_MOVABLE. Use this when work landed in the wrong project (e.g. the default "API Generations" bucket) and the user wants it in a named project — moving is always better than regenerating. For SEVERAL sessions use `bulk_move_sessions` instead: one call, up to 100 sessions, and it reports per-session failures. Caller needs edit/full/owner permission on BOTH the source and target projects (a shared-project member can move a teammate\'s session). Resolve the target project id with `list_projects` first.',
74
74
  {
75
75
  session_id: z.string().describe('The session ObjectId to move (from a generation submit response, chat_list_conversations, or an "Open in Kolbo" link).'),
76
76
  project_id: z.string().describe('Target project ObjectId. Call `list_projects` to resolve a project name to its id.'),
@@ -49,7 +49,7 @@ function registerStockLibraryTools(server, client, options = {}) {
49
49
  'Covers external providers (Pexels photos/videos, Pixabay, Sketchfab 3D, licensed Music) AND Kolbo\'s OWN AI-generated library: thousands of SOUND EFFECTS (mediaType="sfx") and MUSIC tracks (source="kolbo-ai"). Use this to FIND ready-made photos, videos, 3D models, music, or sound effects as b-roll/references/project assets — distinct from generate_* tools which create new content.\n\nFor SOUND EFFECTS or MUSIC, Kolbo supports SEMANTIC "VIBE" SEARCH: pass a natural-language description of the feeling/use ("tense ominous build-up for a horror reveal", "uplifting hopeful corporate background", "retro arcade coin pickup") with source="kolbo-ai" and mediaType="sfx" (or "music") — it matches by meaning, not just keywords. For external visual providers, use concrete keywords.\n\nsource="all" interleaves providers for the requested media type; or pick a single source. Returns assets with source, sourceId, mediaType, dimensions, author, attribution, thumbnail, and downloadable variants. To turn a script into queries first, call analyze_script_for_stock.',
50
50
  {
51
51
  query: z.string().max(200).optional().describe('For visual providers: concrete keywords ("city skyline sunset"). For Kolbo SFX/music (source="kolbo-ai"): a natural-language VIBE works great ("eerie suspenseful drone", "emotional sad piano"). Omit to browse.'),
52
- source: z.enum(['all', 'kolbo-ai', 'pexels', 'pixabay', 'sketchfab', 'music', 'freesound']).optional().describe('Provider. "all" (default) interleaves. "kolbo-ai" = Kolbo\'s own AI SFX + music (best for vibe search). "freesound" = external CC sound effects.'),
52
+ source: z.enum(['all', 'kolbo-ai', 'pexels', 'unsplash', 'pixabay', 'coverr', 'sketchfab', 'music', 'freesound']).optional().describe('Provider. "all" (default) interleaves. "kolbo-ai" = Kolbo\'s own AI SFX + music (best for vibe search). "unsplash" = photos. "coverr" = stock video + music. "freesound" = external CC sound effects. "music" is not available over the API (returns not found) — use "kolbo-ai" or "coverr" for music.'),
53
53
  mediaType: z.enum(['image', 'illustration', 'vector', 'video', '3d', 'music', 'sfx']).optional().describe('Asset type (default "image"). "sfx" = sound effects, "music" = music tracks. Not every source supports every type — call get_stock_sources.'),
54
54
  category: z.string().optional().describe('Category/group chip value (providerParam) from get_stock_categories. For Kolbo SFX these are 77 Soundly-style top-level groups (e.g. Ambience, Animals, Vehicles, Weapons, Water, Designed, Magic, UI) — call get_stock_categories to list them all.'),
55
55
  subcategory: z.string().optional().describe('Kolbo SFX sub-filter within a group (providerParam from get_stock_categories, e.g. Weapons>sword, Water>splash, Footsteps>concrete, Designed>riser). 623 sub-filters across the 77 groups.'),
@@ -57,10 +57,11 @@ function registerStockLibraryTools(server, client, options = {}) {
57
57
  collectionId: z.string().optional().describe('Filter to one Kolbo collection id (from get_stock_collections).'),
58
58
  orientation: z.enum(['horizontal', 'vertical', 'landscape', 'portrait', 'square']).optional().describe('Orientation filter (provider-dependent).'),
59
59
  color: z.string().optional().describe('Color filter (Pixabay named color, or Pexels named/hex color).'),
60
- order: z.enum(['popular', 'latest']).optional().describe('Sort order (Pixabay).'),
60
+ order: z.enum(['popular', 'latest', 'trending', 'newest', 'title', 'duration_asc', 'duration_desc', 'bpm_asc', 'bpm_desc']).optional().describe('Sort order, provider-dependent. "popular"/"latest": Pixabay, Freesound, Coverr. "trending": Coverr. Music (mediaType="music"): "popular", "newest", "title", "duration_asc", "duration_desc", "bpm_asc", "bpm_desc". A provider ignores values it does not support.'),
61
61
  cursor: z.string().optional().describe('Opaque pagination cursor for Sketchfab single-source browse (from a previous response).'),
62
62
  page: z.number().int().min(1).optional().describe('1-based page number (default 1).'),
63
- perPage: z.number().int().min(1).max(80).optional().describe('Results per page (default 24, max 80).')
63
+ perPage: z.number().int().min(1).max(80).optional().describe('Results per page (default 24, max 80).'),
64
+ projectId: z.string().optional().describe('Optional project id. Prices Kolbo music/SFX for that project (e.g. org-project access), so meta.priceCredits matches what importing into that project would cost.')
64
65
  },
65
66
  async (args) => {
66
67
  const q = buildQuery(args);
@@ -136,7 +137,7 @@ function registerStockLibraryTools(server, client, options = {}) {
136
137
  'get_stock_categories',
137
138
  'List the dynamic category chips for stock sources. For external providers: Pixabay/Sketchfab categories + curated Pexels topics. For Kolbo SFX (source="kolbo-ai", mediaType="sfx"): the 77 Soundly-style top-level groups (group=null) AND their 623 sub-filters (each has a `group` pointing to its parent). Pass a row\'s `providerParam` as `category` (groups) or `subcategory` (sub-filters) to search_stock_media.',
138
139
  {
139
- source: z.enum(['kolbo-ai', 'pexels', 'pixabay', 'sketchfab']).optional().describe('Restrict to one source. Use "kolbo-ai" to list the SFX groups + sub-filters.'),
140
+ source: z.enum(['kolbo-ai', 'pexels', 'unsplash', 'pixabay', 'coverr', 'sketchfab', 'freesound']).optional().describe('Restrict to one source. Use "kolbo-ai" to list the SFX groups + sub-filters.'),
140
141
  mediaType: z.string().optional().describe('Restrict to one media type (e.g. "image", "video", "3d", "sfx").')
141
142
  },
142
143
  async (args) => {
@@ -204,12 +205,13 @@ function registerStockLibraryTools(server, client, options = {}) {
204
205
  'get_stock_asset',
205
206
  'Get a single normalized stock asset with all downloadable variants, author, license, and attribution, by source + id. Call after search_stock_media to resolve the exact download URLs (incl. WAV master + MP3 for Kolbo SFX/music).',
206
207
  {
207
- source: z.enum(['kolbo-ai', 'pexels', 'pixabay', 'sketchfab', 'music', 'freesound']).describe('The asset source.'),
208
+ source: z.enum(['kolbo-ai', 'pexels', 'unsplash', 'pixabay', 'coverr', 'sketchfab', 'music', 'freesound']).describe('The asset source. "music" is not available over the API.'),
208
209
  id: z.string().describe('The provider asset id (sourceId).'),
209
- mediaType: z.string().optional().describe('Media type hint (e.g. "video") — needed for sources that share ids across types.')
210
+ mediaType: z.string().optional().describe('Media type hint (e.g. "video") — needed for sources that share ids across types.'),
211
+ projectId: z.string().optional().describe('Optional project id used to price Kolbo music/SFX for that project (same as search_stock_media).')
210
212
  },
211
- async ({ source, id, mediaType }) => {
212
- const q = buildQuery({ mediaType });
213
+ async ({ source, id, mediaType, projectId }) => {
214
+ const q = buildQuery({ mediaType, projectId });
213
215
  const result = await client.get(`/v1/stock/asset/${encodeURIComponent(source)}/${encodeURIComponent(id)}${q ? '?' + q : ''}`);
214
216
  return { content: [{ type: 'text', text: JSON.stringify(result.asset, null, 2) }] };
215
217
  }
@@ -233,7 +235,7 @@ function registerStockLibraryTools(server, client, options = {}) {
233
235
  'import_stock_asset',
234
236
  "Copy a stock asset into the account's Kolbo media library (downloaded to Kolbo's CDN with a stable URL) so it can be used in projects/generations. FREE for images, video and 3D. Kolbo's own MUSIC and SFX are free for subscribers, org members and anyone who already bought the track; for everyone else they COST CREDITS (search results carry the exact price in meta.priceCredits; 0/absent means free for this caller). When a price is shown, tell the user the cost and get agreement BEFORE calling this. Returns the created media library item. Works for Kolbo SFX (source='kolbo-ai', mediaType='sfx') and supported external visual/audio sources. For SYNCI music use import_music_track_to_library; that paid action acquires a clean licensed file.",
235
237
  {
236
- source: z.enum(['kolbo-ai', 'pexels', 'pixabay', 'sketchfab', 'freesound']).describe('The asset source.'),
238
+ source: z.enum(['kolbo-ai', 'pexels', 'unsplash', 'pixabay', 'coverr', 'sketchfab', 'freesound']).describe('The asset source.'),
237
239
  id: z.string().describe('The provider asset id (sourceId).'),
238
240
  mediaType: z.string().optional().describe('Media type hint (e.g. "video", "image", "vector", "3d").'),
239
241
  variant: z.string().optional().describe('Which download variant label to import (from get_stock_asset). Defaults to the best/largest available.'),