@kolbo/mcp 1.48.0 → 1.49.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/README.md CHANGED
@@ -4,7 +4,7 @@ Use [Kolbo AI](https://kolbo.ai) as native tools in Claude Code and Claude Deskt
4
4
 
5
5
  Generate images, videos, music, speech, sound effects, multi-scene campaigns, and conversational chat — all from natural language in your coding environment. 100+ AI models behind Smart Select routing, with reusable Visual DNA profiles for character/style consistency.
6
6
 
7
- **✨ Interactive widgets (v1.30+):** in claude.ai and Claude Desktop, generations render as live Kolbo cards — real-time progress with model + settings chips, an inline result gallery / video player, and one-click **Animate · Edit · Recreate · Download** actions. Library and model searches render as browsable grids with audio preview. Text-only clients (Claude Code, Cursor) keep the classic text responses.
7
+ **✨ Interactive widgets (v1.30+):** in claude.ai, Claude Desktop, and Codex Desktop, generations render as live Kolbo cards — real-time progress with model + settings chips, an inline result gallery / video or audio player, and one-click **Animate · Edit · Recreate · Download** actions. Multi-audio generations render every track with its own player and Download button. Library and model searches render as browsable grids with audio preview. Text-only clients (Claude Code, Codex CLI, Cursor) keep the classic text responses.
8
8
 
9
9
  ## Set up — paste one prompt, or one config block (keyless, no API key)
10
10
 
@@ -113,7 +113,7 @@ Just ask your agent naturally:
113
113
 
114
114
  Without the optional skill, the config block alone already exposes every tool — you just describe what you want. With the skill installed, each of these is also routed to the right MCP tool with the right defaults — UGC mode picks 9:16 + sound-off + no-captions, marketplace mode enforces compliance (pure white bg, no text, no props), product photoshoot mode uses the right aspect for the platform (2:3 Pinterest, 16:9 hero banner, 1:1 IG feed), etc. The routing logic is shared with [Kolbo Code](https://github.com/Zoharvan12/kolbo-code), so the behavior is identical however you connect.
115
115
 
116
- ## Available Tools (86)
116
+ ## Available Tools (95)
117
117
 
118
118
  **Generation**
119
119
  | Tool | Description |
@@ -193,16 +193,18 @@ Every generation tool also accepts an optional `project_id` arg that routes the
193
193
  |------|-------------|
194
194
  | `publish_html_artifact` | Publish an HTML page, SVG, or Mermaid diagram and get a public shareable URL on `sites.kolbo.ai`. Pass `share_token` from a prior publish to update the same URL in place (old content kept in version history). |
195
195
 
196
- **Music Library** (stock / production music)
196
+ **SYNCI Music Library** (licensed production music)
197
197
  | Tool | Description |
198
198
  |------|-------------|
199
- | `search_music_library` | Search the licensed stock-music catalog by keyword + genre/mood/BPM/duration filters. Find a ready-made track (distinct from `generate_music`, which composes a new song). |
200
- | `analyze_script_for_music` | AI: turn a video/voiceover script into a music search (`query`, `mood`, `genre`, `keywords`). |
201
- | `browse_music_library` | Browse the catalog without a query (paginated). |
202
- | `get_music_library_facets` | List available genres, moods, instruments + BPM/duration ranges. |
203
- | `get_music_track_audio` | Get a track's downloadable 128/320/WAV URLs by id. |
204
- | `get_music_track_related` | Get stems + alternate versions of a track. |
205
- | `get_music_track_lyrics` | Get lyrics text, theme, and explicit flag for a track. |
199
+ | `search_music_library` | Search the licensed catalog; results contain watermarked previews only. |
200
+ | `analyze_script_for_music` | Turn a script or scene description into a music search. |
201
+ | `browse_music_library` | Browse the catalog without a query. |
202
+ | `get_music_library_facets` | List genres, moods, instruments, BPM, and duration filters. |
203
+ | `get_music_track_audio` | Get watermarked preview URLs for a track. |
204
+ | `acquire_clean_music_track` | Spend one SYNCI vendor credit and return clean MP3/WAV signed URLs. Idempotent with `request_id`. |
205
+ | `import_music_track_to_library` | Spend one vendor credit and copy a clean MP3/WAV into Kolbo's media library. |
206
+ | `get_music_track_related` | Get stems/alternate-version metadata (purchasing remains unsupported). |
207
+ | `get_music_track_lyrics` | Get lyrics metadata. |
206
208
 
207
209
  **Stock Library** (multi-source stock media: Pexels, Pixabay, Sketchfab 3D, Music)
208
210
  | Tool | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.48.0",
3
+ "version": "1.49.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": {
@@ -43,7 +43,7 @@
43
43
  "dependencies": {
44
44
  "@modelcontextprotocol/ext-apps": "^1.7.4",
45
45
  "@modelcontextprotocol/sdk": "1.29.0",
46
- "form-data": "^4.0.5",
46
+ "form-data": "^4.0.6",
47
47
  "zod": "^3.25.0"
48
48
  },
49
49
  "overrides": {
package/skill/SKILL.md CHANGED
@@ -115,7 +115,8 @@ Each `references/models/*.md` mirrors the matching skill prompt in `kolbo-api/sr
115
115
  | `upload_media` / `list_media` / `get_media` / `get_media_stats` / `favorite_media` / `unfavorite_media` / `delete_media` / `restore_media` / `permanently_delete_media` / `move_media` / `bulk_*_media` / `*_media_folder` | Media library — see `workflows/media-library.md` |
116
116
  | `create_visual_dna` / `generate_character_sheet` / `list_visual_dnas` / `get_visual_dna` / `delete_visual_dna` / `*_visual_dna_folder` (5 folder tools) | Visual DNA (+ character sheet, character folders) — see `workflows/visual-dna.md` |
117
117
  | `list_moodboards` / `get_moodboard` / `list_presets` | Style overlays |
118
- | `search_stock_media` / `get_stock_sources` / `get_stock_categories` / `get_stock_collections` / `get_stock_asset` / `analyze_script_for_stock` / `import_stock_asset` | Stock library (free, no credits) — EXISTING photos / videos / 3D / SFX / music. For stock **music** use `search_stock_media` with `mediaType: "music"` (semantic vibe query, e.g. "uplifting corporate background") → `get_stock_asset` for downloads. The older `*_music_library` tools are deprecated adapters over this — prefer the stock tools. |
118
+ | `search_stock_media` / `get_stock_sources` / `get_stock_categories` / `get_stock_collections` / `get_stock_asset` / `analyze_script_for_stock` / `import_stock_asset` | Stock library (free, no credits) — EXISTING photos / videos / 3D / SFX / music. For stock **music** use `search_stock_media` with `mediaType: "music"` (semantic vibe query, e.g. "uplifting corporate background") → `get_stock_asset` for downloads. |
119
+ | `search_music_library` / `browse_music_library` / `get_music_track_audio` / `acquire_clean_music_track` / `import_music_track_to_library` | SYNCI licensed music. Search/playback is watermarked preview-only. `acquire_clean_music_track` and `import_music_track_to_library` immediately consume one SYNCI vendor credit and return/import an unwatermarked MP3/WAV; reuse `request_id` on retries. |
119
120
  | `list_projects` / `move_session` | Projects: resolve a project NAME → the `project_id` you pass on generation/upload/doc calls; `move_session` relocates a whole session + its media when work landed in the wrong project. NOT the same as `app_builder_list_projects`. See "Projects — Where Work Lands" below. |
120
121
  | `create_project` / `update_project` / `archive_project` / `unarchive_project` / `list_sessions` | Project lifecycle + session inventory (deletion stays in-app). Create a project when the user starts new work, then pass its id on EVERY call. |
121
122
  | `add_project_context` / `list_project_context` / `delete_project_context` / `get_project_profile` / `regenerate_project_profile` | Project knowledge base (RAG): feed scripts/URLs/notes; `get_project_profile` = the living brief — read it to ground work in the project |
@@ -8,7 +8,7 @@ Load this file when the user wants AI-generated **music** — full songs, lyrics
8
8
 
9
9
  **Kolbo MCP routing:** call `generate_music`. Suno is a model option — use `list_models({ type: "music_gen" })` to see versions. Pass `instrumental` and `duration` as separate params; pass the Style/Description text as `style` and the Lyrics as `lyrics`.
10
10
 
11
- **Wants an EXISTING track, not a new song?** ("background music", "stock music", "royalty-free track") → don't generate. Use `search_stock_media` with `mediaType: "music"` (semantic vibe query "tense cinematic pulse", "uplifting corporate background") → `get_stock_asset` for download URLs. Free, no credits. The older `*_music_library` tools are deprecated adapters over the stock library prefer the stock tools.
11
+ **Wants an EXISTING track, not a new song?** ("background music", "stock music", "royalty-free track") → don't generate. Use `search_stock_media` with `mediaType: "music"` for free Kolbo/Coverr results. For SYNCI's licensed catalog use `search_music_library`; its playback URLs are watermarked previews. Final use must call `acquire_clean_music_track` or `import_music_track_to_library`, which immediately consumes one SYNCI vendor credit and returns/imports a clean MP3/WAV. Reuse `request_id` when retrying the same action.
12
12
 
13
13
  ## CRITICAL Kolbo Platform Rules
14
14
 
@@ -69,7 +69,7 @@ const BRIDGE_JS = `
69
69
  } else if (m.method === 'ui/notifications/tool-input' || m.method === 'ui/notifications/tool-input-partial') {
70
70
  // Fires while the tool is still RUNNING — lets widgets show a real
71
71
  // "preparing" state instead of a blank card until the result lands.
72
- toolInputFns.forEach(function (f) { try { f((m.params && m.params.arguments) || {}); } catch (e) {} });
72
+ toolInputFns.forEach(function (f) { try { f((m.params && m.params.arguments) || {}, m.params || {}); } catch (e) {} });
73
73
  } else if (m.method === 'ui/notifications/host-context-changed') {
74
74
  hostContext = (m.params && m.params.hostContext) || m.params || hostContext;
75
75
  themeFns.forEach(function (f) { try { f(hostContext); } catch (e) {} });
package/src/apps/index.js CHANGED
@@ -124,7 +124,19 @@ function appsEnabled(server, opts = {}) {
124
124
  if (opts.apps === true || process.env.KOLBO_MCP_APPS === '1') return true;
125
125
  try {
126
126
  const caps = server?.server?.getClientCapabilities?.();
127
- return getUiCapability(caps) !== undefined;
127
+ if (getUiCapability(caps) !== undefined) return true;
128
+
129
+ // Codex Desktop currently mounts MCP App resources from tool `_meta`, but
130
+ // its initialize handshake identifies as `codex-mcp-client` with an empty
131
+ // capabilities object. Without this compatibility path the host mounts a
132
+ // "Preparing" card while the tool takes the blocking/text fallback, so the
133
+ // completed media never reaches the iframe as structuredContent.
134
+ //
135
+ // Keep the desktop-origin check: Codex CLI uses the same client name but is
136
+ // a text surface, where returning immediately would remove the final URLs.
137
+ const info = server?.server?.getClientVersion?.();
138
+ const origin = process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE || '';
139
+ return info?.name === 'codex-mcp-client' && /codex desktop/i.test(origin);
128
140
  } catch (_) {
129
141
  return false;
130
142
  }
@@ -272,9 +284,9 @@ const TOOL_WIDGETS = {
272
284
  // media grid
273
285
  list_media: UI.mediaGrid,
274
286
  search_stock_media: UI.mediaGrid,
275
- get_stock_collections: UI.mediaGrid,
276
287
  search_music_library: UI.mediaGrid,
277
288
  browse_music_library: UI.mediaGrid,
289
+ get_stock_collections: UI.mediaGrid,
278
290
  list_presets: UI.mediaGrid,
279
291
  list_voices: UI.mediaGrid,
280
292
  list_visual_dnas: UI.mediaGrid,
package/src/apps/theme.js CHANGED
@@ -259,6 +259,24 @@ html.k-fullscreen .k-actions { flex: none; padding-top: 8px; }
259
259
  .k-audio-meta { flex: 1; min-width: 0; }
260
260
  .k-audio-title { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
261
261
  .k-audio-sub { font-size: 11px; color: var(--text-faint); }
262
+ .k-generated-audio {
263
+ display: grid; grid-template-columns: 40px minmax(0, 1fr) auto;
264
+ align-items: center; gap: 7px 10px;
265
+ }
266
+ .k-generated-audio .k-audio-placeholder {
267
+ display: flex; align-items: center; justify-content: center; color: var(--brand);
268
+ }
269
+ .k-generated-audio .k-audio-placeholder svg { width: 19px; height: 19px; }
270
+ .k-generated-audio .k-audio-download { white-space: nowrap; }
271
+ .k-generated-audio .k-audio-player {
272
+ grid-column: 2 / -1; display: block; width: 100%; min-width: 0; height: 34px;
273
+ }
274
+ @media (max-width: 520px) {
275
+ .k-generated-audio { grid-template-columns: 36px minmax(0, 1fr); }
276
+ .k-generated-audio .k-audio-art { width: 36px; height: 36px; }
277
+ .k-generated-audio .k-audio-download { grid-column: 2; justify-self: start; }
278
+ .k-generated-audio .k-audio-player { grid-column: 1 / -1; }
279
+ }
262
280
  .k-play {
263
281
  width: 32px; height: 32px; border-radius: 50%; flex: none; border: 1px solid rgba(255,255,255,0.18);
264
282
  background: rgba(0,0,0,0.5); color: #fff; cursor: pointer;
@@ -17,6 +17,7 @@ const { widgetPage } = require('../html');
17
17
  * settings: { duration, resolution, aspect_ratio, audio, voice, mode },
18
18
  * reference_image, // thumbnail URL (optional)
19
19
  * urls, thumbnail_url, title, duration, credits_used,
20
+ * tracks: [{ title, duration, thumbnail_url, model }], // optional audio metadata by URL index
20
21
  * scenes: [{ scene_number, title, image_urls, video_urls }],
21
22
  * error,
22
23
  * open_url // "Open in Kolbo" target (optional)
@@ -53,6 +54,8 @@ const SCRIPT = `
53
54
  var state = null; // current structuredContent
54
55
  var selected = 0; // selected result index
55
56
  var pollTimer = null;
57
+ var originTool = null;
58
+ var originArgs = {};
56
59
 
57
60
  el('logo').innerHTML = KOLBO_LOGO + '<span>Kolbo</span>';
58
61
  el('action-cancel').innerHTML = ICONS.x;
@@ -122,23 +125,26 @@ function renderGenerating(sc) {
122
125
  schedulePoll(sc);
123
126
  }
124
127
 
125
- // Poll ceilings so the card never spins "Generating" forever. A generation
126
- // that genuinely stalls, or a status call that keeps failing (e.g. the record
127
- // can't be resolved), surfaces an error + Try Again instead of hanging.
128
- var MAX_POLL_MS = 12 * 60 * 1000; // hard wall-clock ceiling (covers slow 4K video)
129
- var MAX_POLL_ERRORS = 12; // ~48s of consecutive status-call failures → give up
128
+ // Poll ceilings so the card never spins forever. Losing widget-side tracking
129
+ // is not a generation failure: keep retries disabled because the paid server
130
+ // job may still complete.
131
+ var MAX_POLL_MS = 35 * 60 * 1000;
132
+ var MAX_POLL_ERRORS = 30;
130
133
  var pollStart = 0, pollErrors = 0;
131
134
 
132
135
  function schedulePoll(sc) {
133
136
  if (!pollStart) pollStart = Date.now();
134
137
  clearTimeout(pollTimer);
135
- pollTimer = setTimeout(function () { poll(sc); }, 4000);
138
+ // The call itself long-waits server-side (normally up to three minutes).
139
+ // This short pause only separates successive wait windows.
140
+ var delay = 1500;
141
+ pollTimer = setTimeout(function () { poll(sc); }, delay);
136
142
  }
137
143
  function poll(sc) {
138
144
  if (pollStart && (Date.now() - pollStart) > MAX_POLL_MS) {
139
- return renderError('This is taking longer than expected and may have stalled. Try again if it keeps happening the model may be busy. Any finished result will also appear in your Kolbo library.');
145
+ return renderTrackingIssue('This is still running longer than the tracking window. Do not retry it any completed result will appear in your Kolbo library.');
140
146
  }
141
- var args = sc.status_args || { generation_id: sc.generation_id };
147
+ var args = sc.status_args || { generation_id: sc.generation_id, wait: true };
142
148
  window.kolbo.callTool(sc.poll_tool || 'get_generation_status', args).then(function (res) {
143
149
  var st = structured(res) || {};
144
150
  var stateName = st.state || st.phase || st.status;
@@ -146,7 +152,7 @@ function poll(sc) {
146
152
  // generation state — count it toward the consecutive-error cap so a record
147
153
  // that can't be resolved errors out fast instead of polling forever.
148
154
  if ((res && res.isError) || st.success === false || (st.error && !stateName)) {
149
- if (++pollErrors >= MAX_POLL_ERRORS) return renderError(st.error || 'Could not track this generation. Please try again.');
155
+ if (++pollErrors >= MAX_POLL_ERRORS) return renderTrackingIssue(st.error || 'Tracking paused. The generation may still be running.');
150
156
  return schedulePoll(sc);
151
157
  }
152
158
  if (stateName === 'completed') {
@@ -163,9 +169,13 @@ function poll(sc) {
163
169
  renderResult(done);
164
170
  // Let the model know the outcome without it having to poll.
165
171
  try {
172
+ var completedUrls = (done.urls || []).slice();
173
+ (done.scenes || []).forEach(function (scene) {
174
+ completedUrls = completedUrls.concat(scene.image_urls || [], scene.video_urls || []);
175
+ });
166
176
  window.kolbo.updateModelContext(
167
177
  'Generation ' + (sc.generation_id || '') + ' completed (' + (sc.tool || '') + ').' +
168
- '\\nOutput URLs:\\n' + (done.urls || []).join('\\n') +
178
+ '\\nOutput URLs:\\n' + completedUrls.join('\\n') +
169
179
  (done.credits_used != null ? '\\nCredits used: ' + done.credits_used : ''));
170
180
  } catch (e) {}
171
181
  } else if (stateName === 'failed' || stateName === 'error' || stateName === 'cancelled') {
@@ -175,7 +185,7 @@ function poll(sc) {
175
185
  schedulePoll(sc);
176
186
  }
177
187
  }).catch(function () {
178
- if (++pollErrors >= MAX_POLL_ERRORS) return renderError('Lost connection while tracking this generation. Please try again.');
188
+ if (++pollErrors >= MAX_POLL_ERRORS) return renderTrackingIssue('Tracking paused after repeated connection errors. The generation may still be running.');
179
189
  schedulePoll(sc);
180
190
  });
181
191
  }
@@ -240,13 +250,35 @@ function renderVideo(sc, urls) {
240
250
 
241
251
  function renderAudio(sc, urls) {
242
252
  el('stage').innerHTML = urls.map(function (u, i) {
243
- var title = sc.title || ((TOOL_TITLES[sc.tool] || 'Audio') + (urls.length > 1 ? ' ' + (i + 1) : ''));
244
- return '<div class="k-audio-row">' +
245
- (sc.thumbnail_url ? '<img class="k-audio-art" src="' + esc(sc.thumbnail_url) + '">' : '<div class="k-audio-art"></div>') +
253
+ var track = (sc.tracks && sc.tracks[i]) || {};
254
+ var titleBase = track.title || sc.title || (TOOL_TITLES[sc.tool] || 'Audio');
255
+ var title = titleBase + (urls.length > 1 ? ' Track ' + (i + 1) : '');
256
+ var duration = track.duration != null ? track.duration : sc.duration;
257
+ var artwork = track.thumbnail_url || sc.thumbnail_url;
258
+ return '<div class="k-audio-row k-generated-audio">' +
259
+ (artwork ? '<img class="k-audio-art" src="' + esc(artwork) + '" alt="">' :
260
+ '<div class="k-audio-art k-audio-placeholder">' + ICONS.audio + '</div>') +
246
261
  '<div class="k-audio-meta"><div class="k-audio-title">' + esc(title) + '</div>' +
247
- '<div class="k-audio-sub">' + esc(sc.model || '') + (sc.duration ? ' · ' + fmtDur(sc.duration) : '') + '</div></div>' +
248
- '<audio src="' + esc(u) + '" controls style="height:32px;max-width:260px"></audio></div>';
262
+ '<div class="k-audio-sub">' + esc(track.model || sc.model || '') +
263
+ (duration ? ' · ' + fmtDur(duration) : '') + '</div></div>' +
264
+ '<button class="k-btn k-audio-download" data-audio-download="' + esc(u) +
265
+ '" aria-label="Download ' + esc(title) + '">' + ICONS.download + ' Download</button>' +
266
+ '<audio class="k-audio-player" src="' + esc(u) + '" controls preload="metadata" aria-label="Play ' +
267
+ esc(title) + '"></audio></div>';
249
268
  }).join('');
269
+ Array.prototype.forEach.call(el('stage').querySelectorAll('[data-audio-download]'), function (b) {
270
+ b.onclick = function () {
271
+ window.kolbo.openLink(downloadUrl(b.getAttribute('data-audio-download')));
272
+ };
273
+ });
274
+ var players = el('stage').querySelectorAll('.k-audio-player');
275
+ Array.prototype.forEach.call(players, function (player) {
276
+ player.addEventListener('play', function () {
277
+ Array.prototype.forEach.call(players, function (other) {
278
+ if (other !== player) other.pause();
279
+ });
280
+ });
281
+ });
250
282
  }
251
283
 
252
284
  function render3d(sc, urls) {
@@ -363,6 +395,22 @@ function renderError(msg) {
363
395
  window.kolbo.notifySize();
364
396
  }
365
397
 
398
+ function renderTrackingIssue(msg) {
399
+ clearTimeout(pollTimer);
400
+ setPhaseChip('Still working', false);
401
+ el('stage').innerHTML = '<div class="k-error">' + ICONS.clock + ' ' + esc(msg) + '</div>';
402
+ el('actions').innerHTML = '<button class="k-btn primary" id="status-btn">' + ICONS.clock + ' Check status</button>' +
403
+ '<button class="k-btn ghost" id="tracking-open">Open in Kolbo ' + ICONS.open + '</button>';
404
+ el('status-btn').onclick = function () {
405
+ window.kolbo.sendMessage('Check the existing Kolbo generation status without retrying it.' +
406
+ (state && state.generation_id ? '\\nGeneration ID: ' + state.generation_id : ''));
407
+ };
408
+ el('tracking-open').onclick = function () {
409
+ window.kolbo.openLink((state && state.open_url) || 'https://app.kolbo.ai');
410
+ };
411
+ window.kolbo.notifySize();
412
+ }
413
+
366
414
  /* ---------- fullscreen viewer ---------- */
367
415
  var isFullscreen = false;
368
416
  function toggleFullscreen() {
@@ -417,9 +465,10 @@ function renderActions(sc) {
417
465
  }
418
466
  if (sc.kind === 'video') {
419
467
  a.push('<button class="k-btn primary" id="btn-download">' + ICONS.download + ' Download</button>');
420
- } else if (hasSingleUrl) {
468
+ } else if (hasSingleUrl && sc.kind !== 'audio') {
421
469
  // Scenes (Creative Director) have no single "current" url — per-item hover
422
- // download buttons cover them instead.
470
+ // download buttons cover them instead. Audio has a visible download button
471
+ // on every track so multi-output generations never download only track 1.
423
472
  a.push('<button class="k-btn" id="btn-download">' + ICONS.download + ' Download</button>');
424
473
  }
425
474
  a.push('<button class="k-btn" id="btn-recreate">' + ICONS.retry + ' Recreate</button>');
@@ -471,6 +520,8 @@ function openPromptRow(placeholder, onSend) {
471
520
  take many seconds (model resolution, file upload, submit). Show a live
472
521
  shell immediately instead of a blank card. */
473
522
  function bootPre(toolName, args) {
523
+ if (toolName) originTool = toolName;
524
+ if (args) originArgs = args;
474
525
  if (state) return; // real data already arrived
475
526
  el('tool-title').textContent = TOOL_TITLES[toolName] || 'Generation';
476
527
  if (args && (args.prompt || args.text)) {
@@ -484,23 +535,61 @@ function bootPre(toolName, args) {
484
535
  window.kolbo.notifySize();
485
536
  }
486
537
 
538
+ function kindFromTool(tool, sc) {
539
+ if (sc && sc.scenes) return 'scenes';
540
+ if (/video|lipsync|elements|first_last/i.test(tool || '')) return 'video';
541
+ if (/music|speech|sound/i.test(tool || '')) return 'audio';
542
+ if (/3d/i.test(tool || '')) return '3d';
543
+ var firstUrl = sc && Array.isArray(sc.urls) ? String(sc.urls[0] || '').split('?')[0].toLowerCase() : '';
544
+ if (/\\.(mp4|mov|webm|mkv)$/.test(firstUrl)) return 'video';
545
+ if (/\\.(mp3|wav|m4a|aac|ogg|flac)$/.test(firstUrl)) return 'audio';
546
+ if (/\\.(glb|gltf|fbx|obj|usdz)$/.test(firstUrl)) return '3d';
547
+ return 'image';
548
+ }
549
+
550
+ // Recover a successful legacy/text result when a host mounted the iframe from
551
+ // declaration metadata but the server did not recognize its Apps capability.
552
+ function completedFromPlain(sc) {
553
+ if (!sc || (!Array.isArray(sc.urls) && !Array.isArray(sc.scenes))) return null;
554
+ return Object.assign({}, sc, {
555
+ widget: 'generation',
556
+ phase: 'completed',
557
+ tool: originTool,
558
+ kind: kindFromTool(originTool, sc),
559
+ prompt: originArgs.prompt || originArgs.text || sc.prompt_used || '',
560
+ model: sc.model || originArgs.model,
561
+ settings: {
562
+ duration: sc.duration || originArgs.duration,
563
+ resolution: originArgs.resolution,
564
+ aspect_ratio: originArgs.aspect_ratio
565
+ },
566
+ urls: sc.urls || []
567
+ });
568
+ }
569
+
487
570
  /* ---------- wire host events ---------- */
488
571
  window.kolbo.onToolResult(function (result) {
489
572
  var sc = result.structuredContent || structured(result);
490
573
  if (sc && (sc.phase || sc.widget)) return boot(sc);
574
+ var recovered = completedFromPlain(sc);
575
+ if (recovered) return boot(recovered);
491
576
  // Tool errored (or returned plain text): show it instead of a dead blank card.
492
577
  var txt = '';
493
578
  try { txt = (result.content || []).filter(function (c) { return c.type === 'text'; }).map(function (c) { return c.text; }).join(' '); } catch (e) {}
579
+ if (/timed out|timeout/i.test(txt)) {
580
+ return renderTrackingIssue((txt || 'Tracking timed out. The generation may still be running.').slice(0, 300));
581
+ }
494
582
  if (result.isError || /error|failed/i.test(txt)) {
495
583
  renderError((txt || 'The request failed.').slice(0, 300));
496
584
  }
497
585
  });
498
- window.kolbo.onToolInput(function (args) { bootPre(null, args); });
586
+ window.kolbo.onToolInput(function (args, info) { bootPre(info && info.name, args); });
499
587
  window.kolbo.ready(function (ctx) {
500
588
  var info = ctx && ctx.toolInfo;
501
589
  if (!state && info) {
590
+ originTool = (info.tool && info.tool.name) || originTool;
502
591
  if (info.result && info.result.structuredContent) return boot(info.result.structuredContent);
503
- bootPre(info.tool && info.tool.name, null);
592
+ bootPre(originTool, null);
504
593
  }
505
594
  });
506
595
  `;
@@ -36,7 +36,7 @@ const BODY = `
36
36
  const SCRIPT = `
37
37
  el('logo').innerHTML = KOLBO_LOGO + '<span>Kolbo</span>';
38
38
  el('kolbo-link').onclick = function (e) { e.preventDefault(); window.kolbo.openLink((state && state.open_url) || 'https://app.kolbo.ai'); };
39
- var state = null, pollTimer = null;
39
+ var state = null, pollTimer = null, originArgs = {};
40
40
 
41
41
  function boot(sc) {
42
42
  if (!sc) return;
@@ -83,40 +83,68 @@ function boot(sc) {
83
83
  }
84
84
 
85
85
  var pollStart = 0, pollErrors = 0;
86
- var MAX_POLL_MS = 12 * 60 * 1000, MAX_POLL_ERRORS = 12;
86
+ var MAX_POLL_MS = 35 * 60 * 1000, MAX_POLL_ERRORS = 30;
87
87
  function poll() {
88
88
  if (!pollStart) pollStart = Date.now();
89
89
  if ((Date.now() - pollStart) > MAX_POLL_MS) {
90
- return boot(Object.assign({}, state, { phase: 'failed', error: 'Transcription is taking longer than expected and may have stalledplease try again.' }));
90
+ return trackingIssue('This transcription is still running longer than the tracking window. Do not retry it any completed result will appear in your Kolbo library.');
91
91
  }
92
- window.kolbo.callTool(state.poll_tool || 'get_generation_status', { generation_id: state.generation_id })
92
+ var args = state.status_args || { generation_id: state.generation_id, wait: true };
93
+ window.kolbo.callTool(state.poll_tool || 'get_generation_status', args)
93
94
  .then(function (res) {
94
95
  var st = structured(res) || {};
95
96
  var s = st.state || st.phase;
96
97
  if ((res && res.isError) || st.success === false || (st.error && !s)) {
97
- if (++pollErrors >= MAX_POLL_ERRORS) return boot(Object.assign({}, state, { phase: 'failed', error: st.error || 'Could not track this transcription. Please try again.' }));
98
- pollTimer = setTimeout(poll, 5000); return;
98
+ if (++pollErrors >= MAX_POLL_ERRORS) return trackingIssue(st.error || 'Tracking paused. The transcription may still be running.');
99
+ pollTimer = setTimeout(poll, pollDelay()); return;
99
100
  }
100
101
  if (s === 'completed') {
101
102
  var r = st.result || st;
102
103
  boot(Object.assign({}, state, r, { phase: 'completed', credits_used: st.credits_used }));
103
104
  } else if (s === 'failed' || s === 'cancelled') {
104
105
  boot(Object.assign({}, state, { phase: 'failed', error: st.error }));
105
- } else { pollErrors = 0; pollTimer = setTimeout(poll, 5000); }
106
+ } else { pollErrors = 0; pollTimer = setTimeout(poll, pollDelay()); }
106
107
  }).catch(function () {
107
- if (++pollErrors >= MAX_POLL_ERRORS) return boot(Object.assign({}, state, { phase: 'failed', error: 'Lost connection while tracking this transcription. Please try again.' }));
108
- pollTimer = setTimeout(poll, 5000);
108
+ if (++pollErrors >= MAX_POLL_ERRORS) return trackingIssue('Tracking paused after repeated connection errors. The transcription may still be running.');
109
+ pollTimer = setTimeout(poll, pollDelay());
109
110
  });
110
111
  }
111
112
 
113
+ function pollDelay() {
114
+ // The call itself long-waits server-side; only briefly back off between
115
+ // successive wait windows.
116
+ return 1500;
117
+ }
118
+
119
+ function trackingIssue(msg) {
120
+ clearTimeout(pollTimer);
121
+ el('phase-chip').style.display = '';
122
+ el('phase-chip').textContent = 'Still working';
123
+ el('stage').classList.remove('k-empty');
124
+ el('stage').innerHTML = '<div class="k-error">' + ICONS.clock + ' ' + esc(msg) + '</div>';
125
+ el('actions').innerHTML = '<button class="k-btn primary" id="transcript-status">' + ICONS.clock + ' Check status</button>';
126
+ el('transcript-status').onclick = function () {
127
+ window.kolbo.sendMessage('Check the existing Kolbo transcription status without retrying it.' +
128
+ (state && state.generation_id ? '\\nGeneration ID: ' + state.generation_id : ''));
129
+ };
130
+ window.kolbo.notifySize();
131
+ }
132
+
112
133
  window.kolbo.onToolResult(function (result) {
113
134
  var sc = result.structuredContent || structured(result);
114
135
  // Only boot on a real widget contract (phase present). A text-JSON
115
136
  // fallback like { status: 'submitted' } has no phase and would render a
116
137
  // bogus "(empty transcript)" completed view.
117
138
  if (sc && (sc.phase || sc.widget === 'transcript')) return boot(sc);
139
+ if (sc && (typeof sc.text === 'string' || sc.srt_url || sc.txt_url)) {
140
+ return boot(Object.assign({}, sc, {
141
+ widget: 'transcript', phase: 'completed',
142
+ audio_url: originArgs.source && /^https?:\\/\\//i.test(originArgs.source) ? originArgs.source : undefined
143
+ }));
144
+ }
118
145
  var txt = '';
119
146
  try { txt = (result.content || []).filter(function (c) { return c.type === 'text'; }).map(function (c) { return c.text; }).join(' '); } catch (e) {}
147
+ if (/timed out|timeout/i.test(txt)) return trackingIssue((txt || 'Tracking timed out.').slice(0, 300));
120
148
  if (result.isError || /error|failed/i.test(txt)) {
121
149
  el('phase-chip').style.display = 'none';
122
150
  el('stage').classList.remove('k-empty');
@@ -129,6 +157,7 @@ window.kolbo.onToolResult(function (result) {
129
157
  if (card) card.style.display = 'none';
130
158
  window.kolbo.notifySize();
131
159
  });
160
+ window.kolbo.onToolInput(function (args) { originArgs = args || {}; });
132
161
  `;
133
162
 
134
163
  function transcriptWidgetHtml() {
package/src/index.js CHANGED
@@ -70,9 +70,9 @@ const { registerArtifactTools } = require('./tools/artifacts');
70
70
  const { registerProjectTools } = require('./tools/projects');
71
71
  const { registerAgentTools } = require('./tools/agents');
72
72
  const { registerDocTools } = require('./tools/docs');
73
- const { registerVoiceTools } = require('./tools/voices');
74
- const { registerMusicLibraryTools } = require('./tools/music_library');
75
- const { registerStockLibraryTools } = require('./tools/stock_library');
73
+ const { registerVoiceTools } = require('./tools/voices');
74
+ const { registerMusicLibraryTools } = require('./tools/music_library');
75
+ const { registerStockLibraryTools } = require('./tools/stock_library');
76
76
  const { registerApps, attachToolWidgetMeta } = require('./apps');
77
77
 
78
78
  /**
@@ -139,9 +139,9 @@ function createServer(opts = {}) {
139
139
  registerArtifactTools(server, client, toolOptions);
140
140
  registerProjectTools(server, client, toolOptions);
141
141
  registerAgentTools(server, client, toolOptions);
142
- registerDocTools(server, client, toolOptions);
143
- registerMusicLibraryTools(server, client, toolOptions);
144
- registerStockLibraryTools(server, client, toolOptions);
142
+ registerDocTools(server, client, toolOptions);
143
+ registerMusicLibraryTools(server, client, toolOptions);
144
+ registerStockLibraryTools(server, client, toolOptions);
145
145
 
146
146
  // MCP Apps widget resources (ui://kolbo/*). Registering resources is inert
147
147
  // for text-only hosts — they never fetch them.
@@ -363,6 +363,19 @@ function buildOpenUrl(tool, gen) {
363
363
  return url;
364
364
  }
365
365
 
366
+ /**
367
+ * Build the "Open in Kolbo" deep link for a PROJECT. Lands on the Media hub
368
+ * (all-assets view) pre-filtered to this project, where the user sees every
369
+ * generation/media item in it and can switch projects via the in-page selector.
370
+ * The media page reads `?project=<id>` on load and selects it. Returns undefined
371
+ * for a missing id or the default "API Generations" bucket (no useful landing —
372
+ * it is the catch-all, not a real workspace the user navigates to).
373
+ */
374
+ function buildProjectUrl(projectId, opts = {}) {
375
+ if (!projectId || opts.is_default) return undefined;
376
+ return `${APP_BASE_URL}/media?project=${encodeURIComponent(projectId)}`;
377
+ }
378
+
366
379
  // ─── MCP Apps generation widget helpers ──────────────────────────────────────
367
380
  // When the host renders MCP Apps (claude.ai via the remote connector, Claude
368
381
  // Desktop over stdio), generation tools return IMMEDIATELY after submit and the
@@ -380,7 +393,7 @@ const { UI, uiResult, appsEnabled, modelIcon } = require('../apps');
380
393
  * client KolboClient (for model icon lookup)
381
394
  * model, prompt, count, settings, reference_image, estimated_seconds
382
395
  * poll_tool widget-side status tool (default 'get_generation_status')
383
- * status_args args for poll_tool (default { generation_id })
396
+ * status_args args for poll_tool (default { generation_id, wait: true })
384
397
  */
385
398
  async function uiGenerating(p) {
386
399
  // No ETAs anywhere — just a spinner until the poll flips to completed.
@@ -392,7 +405,10 @@ async function uiGenerating(p) {
392
405
  tool: p.tool,
393
406
  generation_id: p.gen.generation_id,
394
407
  poll_tool: p.poll_tool || 'get_generation_status',
395
- status_args: p.status_args,
408
+ // Keep at most one long-wait status call in flight per widget. Without
409
+ // wait=true, every open card calls tools/call every few seconds, flooding
410
+ // the host's global progress/context stream and API rate limits.
411
+ status_args: p.status_args || { generation_id: p.gen.generation_id, wait: true },
396
412
  model: p.model || 'Smart Select',
397
413
  model_icon: icon,
398
414
  prompt: p.prompt,
@@ -451,6 +467,7 @@ module.exports = {
451
467
  projectScopeReadField,
452
468
  inlineImageBlocks,
453
469
  buildOpenUrl,
470
+ buildProjectUrl,
454
471
  uiGenerating,
455
472
  uiCompleted,
456
473
  appsEnabled,
@@ -6,7 +6,7 @@
6
6
  const { z } = require('zod');
7
7
  const FormData = require('form-data');
8
8
  const { pollUntilDone } = require('../polling');
9
- const { resolveToBuffer, creditFields, projectIdField, inlineImageBlocks, buildOpenUrl, uiGenerating, uiCompleted, appsEnabled } = require('./_shared');
9
+ const { resolveToBuffer, creditFields, projectIdField, inlineImageBlocks, buildOpenUrl, uiGenerating, appsEnabled } = require('./_shared');
10
10
  const { UI, uiResult, canonicalModelId } = require('../apps');
11
11
 
12
12
  // ─── Cinematic Dimensions schema (shared by generate_image + generate_image_edit) ───
@@ -43,8 +43,8 @@ function registerGenerateTools(server, client, options = {}) {
43
43
  // tool output is unchanged: a text block with the image URL.
44
44
  const inlineImages = !!options.inlineImages;
45
45
  // MCP Apps hosts (claude.ai remote connector, Claude Desktop) get an instant
46
- // "submitted" response + a live ui://kolbo/generation.html widget that polls
47
- // get_generation_status itself. Text-only hosts never take this branch.
46
+ // "submitted" response + a live ui://kolbo/generation.html widget that keeps
47
+ // one wait=true status call in flight. Text-only hosts never take this branch.
48
48
  const ui = () => appsEnabled(server, options);
49
49
  // ─── generate_image ────────────────────────────────────────
50
50
  server.tool(
@@ -183,6 +183,15 @@ function registerGenerateTools(server, client, options = {}) {
183
183
  });
184
184
 
185
185
  const cdStatusUrl = `/v1/generate/creative-director/${gen.generation_id}/status`;
186
+ if (ui()) return uiGenerating({
187
+ tool: 'generate_creative_director', kind: 'scenes', gen, client, model, prompt,
188
+ count: scene_count || 4,
189
+ settings: { duration, resolution, aspect_ratio, mode: workflow_type || 'image' },
190
+ reference_image: reference_images?.[0],
191
+ poll_tool: 'get_creative_director_status',
192
+ status_args: { generation_id: gen.generation_id, wait: true }
193
+ });
194
+
186
195
  // Video mode runs N scenes as parallel video generations, each of which
187
196
  // can take several minutes — a whole batch routinely exceeds the 10-min
188
197
  // image window. Give video batches 30 min (the server watchdog finalizes
@@ -237,15 +246,6 @@ function registerGenerateTools(server, client, options = {}) {
237
246
  _followup_hint: 'Each scene is a separate asset. If the user asks to edit one scene, find that scene by scene_number/title and pass its image_urls[0] (or video_urls[0]) to generate_image_edit / edit_image / edit_video / generate_video_from_video. Do NOT re-run generate_creative_director unless the user explicitly wants a brand-new set.'
238
247
  }, null, 2);
239
248
 
240
- // Creative Director polls a dedicated status route the widget can't reach
241
- // through get_generation_status, so it stays blocking on UI hosts too and
242
- // renders the completed scene gallery.
243
- if (ui()) return uiCompleted({
244
- tool: 'generate_creative_director', kind: 'scenes', gen, client, model, prompt,
245
- settings: { duration, resolution, mode: workflow_type }, scenes,
246
- credits_used: creditFields(result).credits_used
247
- }, cdText);
248
-
249
249
  return { content: [{ type: 'text', text: cdText }] };
250
250
  }
251
251
  );
@@ -258,12 +258,30 @@ function registerGenerateTools(server, client, options = {}) {
258
258
  // blocking poll window) needs this tool to be re-checked until done.
259
259
  server.tool(
260
260
  'get_creative_director_status',
261
- 'Check the status of a Creative Director batch (from generate_creative_director) by its generation_id. Returns overall state ("processing" until EVERY scene is terminal, then "completed"/"failed") plus each scene\'s number, title, per-scene status, and image_urls/video_urls. Use this to resume checking a batch that was still running when generate_creative_director returned `_timed_out: true` — poll it until state="completed" to collect ALL parallel scene outputs at once. Do NOT use the generic get_generation_status for Creative Director ids; it points at the wrong endpoint.',
261
+ 'Check the status of a Creative Director batch (from generate_creative_director) by its generation_id. Returns overall state ("processing" until EVERY scene is terminal, then "completed"/"failed") plus each scene\'s number, title, per-scene status, and image_urls/video_urls. Set wait=true to block for up to ~3 minutes instead of repeatedly calling this tool. Do NOT use the generic get_generation_status for Creative Director ids; it points at the wrong endpoint.',
262
262
  {
263
- generation_id: z.string().describe('The Creative Director generation_id returned by generate_creative_director.')
263
+ generation_id: z.string().describe('The Creative Director generation_id returned by generate_creative_director.'),
264
+ wait: z.boolean().optional().describe('If true, block until the batch is terminal, up to ~3 minutes. Use this instead of repeatedly checking in a loop.')
264
265
  },
265
- async ({ generation_id }) => {
266
- const status = await client.get(`/v1/generate/creative-director/${encodeURIComponent(generation_id)}/status`);
266
+ async ({ generation_id, wait }) => {
267
+ const statusUrl = `/v1/generate/creative-director/${encodeURIComponent(generation_id)}/status`;
268
+ let status;
269
+ if (wait) {
270
+ try {
271
+ status = await pollUntilDone(client, generation_id, {
272
+ interval: 15000,
273
+ timeout: 180000,
274
+ statusUrl
275
+ });
276
+ } catch (err) {
277
+ // A tracking timeout is not a failed paid generation. Return the
278
+ // latest batch state so the widget can start one new long wait.
279
+ if (!err?.timedOut) throw err;
280
+ status = await client.get(statusUrl);
281
+ }
282
+ } else {
283
+ status = await client.get(statusUrl);
284
+ }
267
285
  const scenes = (status.scenes || []).map(s => ({
268
286
  scene_number: s.scene_number,
269
287
  status: s.status,
@@ -273,6 +291,7 @@ function registerGenerateTools(server, client, options = {}) {
273
291
  }));
274
292
  const completed = scenes.filter(s => s.status === 'completed').length;
275
293
  return { content: [{ type: 'text', text: JSON.stringify({
294
+ ...creditFields(status),
276
295
  state: status.state,
277
296
  generation_id,
278
297
  progress: status.progress,
@@ -281,7 +300,7 @@ function registerGenerateTools(server, client, options = {}) {
281
300
  completed_scenes: completed,
282
301
  _hint: status.state === 'completed'
283
302
  ? 'All scenes terminal. Every completed scene\'s image_urls/video_urls are final.'
284
- : 'Still running call get_creative_director_status again in a few seconds until state="completed".'
303
+ : 'Still running. Call get_creative_director_status once with wait=true; do not poll it in a loop.'
285
304
  }, null, 2) }] };
286
305
  }
287
306
  );
@@ -596,7 +615,10 @@ function registerGenerateTools(server, client, options = {}) {
596
615
  const checkOne = async (id) => {
597
616
  try {
598
617
  if (wait) {
599
- const result = await pollUntilDone(client, id, { interval: 5000, timeout: 180000 });
618
+ // Widgets use this long-wait path. A 15s API check cadence keeps
619
+ // completion responsive without multiplying backend traffic for
620
+ // every card left open in a host conversation.
621
+ const result = await pollUntilDone(client, id, { interval: 15000, timeout: 180000 });
600
622
  return { generation_id: id, ...result };
601
623
  }
602
624
  const result = await client.get(`/v1/generate/${encodeURIComponent(id)}/status`);
@@ -621,8 +643,8 @@ function registerGenerateTools(server, client, options = {}) {
621
643
  : 'Some generations are still processing. Do NOT re-call this tool in a loop — call it ONCE with wait=true (and all pending generation_ids) to block until they finish.';
622
644
 
623
645
  // Single-id calls keep the original flat shape — the generation widget
624
- // polls this tool with { generation_id } and reads state/result at the
625
- // top level.
646
+ // waits on this tool with { generation_id, wait:true } and reads
647
+ // state/result at the top level.
626
648
  if (!generation_ids || generation_ids.length === 0) {
627
649
  const single = results[0];
628
650
  single._hint = pending.length === 0
@@ -1069,6 +1091,7 @@ function registerGenerateTools(server, client, options = {}) {
1069
1091
  widget: 'transcript', phase: 'generating',
1070
1092
  generation_id: startResponse.generation_id,
1071
1093
  poll_tool: 'get_generation_status',
1094
+ status_args: { generation_id: startResponse.generation_id, wait: true },
1072
1095
  audio_url: isUrl ? source : undefined,
1073
1096
  open_url: buildOpenUrl('transcribe_audio', startResponse),
1074
1097
  });
@@ -1,236 +1,203 @@
1
- /* BACKWARD COMPATIBILITY: Tool names and arg names below are a PUBLIC
2
- * CONTRACT. Never rename, remove, or break an existing tool/arg. Full rules: ../index.js top-of-file.
3
- *
4
- * DEPRECATED FAMILY (2026-07-11): these tools originally fronted the dedicated
5
- * Synci-only /v1/music-library/* routes. The Synci partner key expired and the
6
- * unified STOCK LIBRARY covers music anyway (kolbo-ai catalog + Coverr + Synci
7
- * when its key is live), so every tool here is now a thin adapter over
8
- * /v1/stock/* — same tool names/args, better backend, and old cached installs
9
- * keep working. New integrations should use search_stock_media /
10
- * get_stock_asset with mediaType "music" directly.
11
- */
1
+ /* Public MCP contract: keep existing tool and argument names backward compatible. */
12
2
 
13
3
  const { z } = require('zod');
14
4
  const { UI, uiResult, appsEnabled } = require('../apps');
15
5
 
16
- const DEPRECATION_NOTE = '[Deprecated — served by the unified Stock Library now; prefer search_stock_media / get_stock_asset with mediaType "music".] ';
17
-
18
- // Format a stock music asset into a compact human-readable line.
19
- function assetTrackLine(a) {
6
+ function trackLine(track) {
20
7
  const meta = [
21
- a.durationSeconds != null ? `${Math.round(a.durationSeconds)}s` : null,
22
- a.author?.name || null,
23
- a.source,
8
+ track.durationSeconds != null ? `${Math.round(track.durationSeconds)}s` : null,
9
+ track.artist || null,
10
+ track.bpm ? `${track.bpm} BPM` : null,
11
+ track.hqAvailable ? 'WAV available' : 'MP3 only',
24
12
  ].filter(Boolean).join(' · ');
25
- const preview = a.previewUrl || a.downloadVariants?.[0]?.url || null;
26
- return `${a.source}:${a.sourceId} — ${a.title || '(untitled)'}\n ${meta}${preview ? `\n preview: ${preview}` : ''}`;
13
+ return `[${track.id}] ${track.title || '(untitled)'}${meta ? `\n ${meta}` : ''}`;
27
14
  }
28
15
 
29
- // Map a stock music asset onto the media-grid widget item contract.
30
- function assetTrackItem(a) {
16
+ function trackItem(track) {
31
17
  return {
32
- id: `${a.source}:${a.sourceId}`,
33
- title: a.title || '(untitled)',
34
- subtitle: [
35
- a.author?.name || null,
36
- a.durationSeconds != null ? Math.round(a.durationSeconds) + 's' : null,
37
- a.source,
38
- ].filter(Boolean).join(' · '),
39
- thumbnail: a.thumbnailUrl || null,
18
+ id: track.id,
19
+ title: track.title || '(untitled)',
20
+ subtitle: [track.artist, track.durationSeconds != null ? `${Math.round(track.durationSeconds)}s` : null]
21
+ .filter(Boolean).join(' · '),
22
+ thumbnail: track.artworkUrl || null,
40
23
  media_type: 'audio',
41
- preview_audio: a.previewUrl || a.downloadVariants?.[0]?.url || null,
42
- use_hint: 'Get download links for track "{TITLE}" (id: {ID}) via get_music_track_audio.'
24
+ preview_audio: track.previewAudioUrl || track.audioUrl || track.audioUrl128 || null,
25
+ use_hint: `Acquire a clean track with acquire_clean_music_track track_id="${track.id}" format="mp3".`,
43
26
  };
44
27
  }
45
28
 
46
- // Search the unified stock library for music. `query` may be empty (browse).
47
- async function stockMusicSearch(client, query, limit, offset) {
48
- const params = new URLSearchParams();
49
- if (query) params.set('query', query);
50
- params.set('mediaType', 'music');
51
- params.set('source', 'all');
52
- params.set('perPage', String(Math.min(Math.max(limit || 20, 1), 40)));
53
- if (offset) params.set('page', String(Math.floor(offset / (limit || 20)) + 1));
54
- const result = await client.get(`/v1/stock/search?${params.toString()}`);
55
- return { assets: result.assets || [], total: result.total };
56
- }
57
-
58
- // "source:sourceId" (new) or a bare legacy id (assume synci — the old backend).
59
- function parseTrackId(trackId) {
60
- const i = String(trackId).indexOf(':');
61
- if (i > 0) return { source: trackId.slice(0, i), id: trackId.slice(i + 1) };
62
- return { source: 'synci', id: trackId };
29
+ function tracksResult(ui, title, tracks, total) {
30
+ if (!tracks.length) return { content: [{ type: 'text', text: 'No SYNCI tracks found.' }] };
31
+ const text = [
32
+ `Found ${tracks.length} track${tracks.length === 1 ? '' : 's'}${total ? ` (of ${total})` : ''}.`,
33
+ 'Playback URLs are watermarked previews. Use acquire_clean_music_track for final use; it consumes one SYNCI vendor credit.',
34
+ '',
35
+ tracks.map(trackLine).join('\n\n'),
36
+ ].join('\n');
37
+ if (!ui()) return { content: [{ type: 'text', text }] };
38
+ return uiResult(UI.mediaGrid, text, {
39
+ widget: 'media-grid',
40
+ title,
41
+ items: tracks.slice(0, 20).map(trackItem),
42
+ total: total != null ? total : tracks.length,
43
+ });
63
44
  }
64
45
 
65
- function musicResult(ui, args, assets, total, emptyText) {
66
- if (assets.length === 0) {
67
- return { content: [{ type: 'text', text: emptyText }] };
68
- }
69
- const head = `Found ${assets.length} track${assets.length === 1 ? '' : 's'}${total ? ` (of ${total})` : ''}:`;
70
- const text = `${head}\n\n${assets.map(assetTrackLine).join('\n\n')}\n\nUse the track id with get_music_track_audio to get downloadable URLs (or get_stock_asset directly).`;
71
- if (ui()) {
72
- return uiResult(UI.mediaGrid, text, {
73
- widget: 'media-grid',
74
- title: 'Music Library' + (args && args.query ? ' — "' + args.query + '"' : ''),
75
- items: assets.slice(0, 20).map(assetTrackItem),
76
- total: total != null ? total : assets.length
77
- });
78
- }
79
- return { content: [{ type: 'text', text }] };
46
+ function cleanResult(result, requestId) {
47
+ return {
48
+ content: [{
49
+ type: 'text',
50
+ text: JSON.stringify({
51
+ track_id: result.trackId,
52
+ format: result.format,
53
+ audio_url: result.audioUrl,
54
+ download_url: result.downloadUrl,
55
+ watermarked: false,
56
+ credits_remaining: result.creditsRemaining,
57
+ request_id: requestId,
58
+ reused: !!result.reused,
59
+ }, null, 2),
60
+ }],
61
+ };
80
62
  }
81
63
 
82
64
  function registerMusicLibraryTools(server, client, options = {}) {
83
65
  const ui = () => appsEnabled(server, options);
84
66
 
85
- // ─── search_music_library (adapter → stock search) ─────────────
86
67
  server.tool(
87
68
  'search_music_library',
88
- DEPRECATION_NOTE + 'Search stock/production music by keyword. Mood/genre are folded into the semantic query (the Kolbo catalog matches by vibe, e.g. "uplifting corporate", "tense cinematic"). Returns tracks with id, title, duration, and preview/download URLs. For scripts, call analyze_script_for_music first.',
69
+ 'Search the licensed SYNCI catalog. Results contain watermarked preview audio only. For any download or timeline use, call acquire_clean_music_track, which consumes one SYNCI vendor credit.',
89
70
  {
90
- query: z.string().max(200).optional().describe('Keyword/vibe search, e.g. "uplifting corporate", "tense cinematic", "lofi hip hop". If omitted, falls back to the mood/genre filter as the search term.'),
91
- mood: z.string().optional().describe('Mood keyword, folded into the search query (e.g. "Emotional", "Energetic").'),
92
- genre: z.string().optional().describe('Genre keyword, folded into the search query (e.g. "Corporate", "Hip Hop").'),
93
- bpmMin: z.number().optional().describe('Legacy filter — accepted but no longer applied.'),
94
- bpmMax: z.number().optional().describe('Legacy filter — accepted but no longer applied.'),
95
- durationMin: z.number().optional().describe('Legacy filter — accepted but no longer applied.'),
96
- durationMax: z.number().optional().describe('Legacy filter — accepted but no longer applied.'),
97
- hasStems: z.boolean().optional().describe('Legacy filter — accepted but no longer applied.'),
98
- hasLyrics: z.boolean().optional().describe('Legacy filter — accepted but no longer applied.'),
99
- sort: z.enum(['duration-asc', 'duration-desc', 'bpm-asc', 'bpm-desc', 'title']).optional().describe('Legacy sort — accepted but no longer applied (relevance order).'),
100
- limit: z.number().int().min(1).max(40).optional().describe('Results per page (max 40, default 20).'),
101
- offset: z.number().int().min(0).optional().describe('Pagination offset for loading more results.')
71
+ query: z.string().max(200).optional(),
72
+ mood: z.string().optional(),
73
+ genre: z.string().optional(),
74
+ bpmMin: z.number().optional(),
75
+ bpmMax: z.number().optional(),
76
+ durationMin: z.number().optional(),
77
+ durationMax: z.number().optional(),
78
+ hasStems: z.boolean().optional(),
79
+ hasLyrics: z.boolean().optional(),
80
+ sort: z.enum(['duration-asc', 'duration-desc', 'bpm-asc', 'bpm-desc', 'title']).optional(),
81
+ limit: z.number().int().min(1).max(50).optional(),
82
+ offset: z.number().int().min(0).optional(),
102
83
  },
103
84
  async (args) => {
104
- const query = [args.query, args.mood, args.genre].filter(Boolean).join(' ').trim();
105
- const { assets, total } = await stockMusicSearch(client, query, args.limit, args.offset);
106
- return musicResult(ui, args, assets, total,
107
- 'No tracks found matching that query. Try a broader vibe description, or use search_stock_media with mediaType="music".');
108
- }
85
+ const result = await client.post('/v1/music-library/search', args);
86
+ return tracksResult(ui, `SYNCI ${args.query || 'Search'}`, result.tracks || [], result.total);
87
+ },
109
88
  );
110
89
 
111
- // ─── analyze_script_for_music (unchanged — doesn't touch Synci) ─
112
90
  server.tool(
113
91
  'analyze_script_for_music',
114
- 'AI helper that turns a video or voiceover script into a music search. Returns { query, mood, genre, keywords } you can pass straight into search_music_library (or search_stock_media with mediaType="music") to find a fitting background track. Use this first when the user gives you a script/scene description rather than explicit music keywords.',
115
- {
116
- script: z.string().min(1).describe('The video or voiceover script / scene description to analyze (up to ~8000 chars).')
117
- },
92
+ 'Turn a script or scene description into a SYNCI music search.',
93
+ { script: z.string().min(1).max(8000) },
118
94
  async ({ script }) => {
119
95
  const result = await client.post('/v1/music-library/analyze-script', { script });
120
- return {
121
- content: [{
122
- type: 'text',
123
- text: JSON.stringify({
124
- query: result.query,
125
- mood: result.mood,
126
- genre: result.genre,
127
- keywords: result.keywords,
128
- _followup_hint: 'Pass query + mood + genre into search_music_library (or search_stock_media mediaType="music").'
129
- }, null, 2)
130
- }]
131
- };
132
- }
96
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
97
+ },
133
98
  );
134
99
 
135
- // ─── browse_music_library (adapter → stock browse feed) ────────
136
100
  server.tool(
137
101
  'browse_music_library',
138
- DEPRECATION_NOTE + 'Browse the music catalog without a search query (paginated feed). For targeted search use search_music_library or search_stock_media.',
102
+ 'Browse the licensed SYNCI catalog. Playback remains watermarked; final use requires acquire_clean_music_track.',
139
103
  {
140
- sort: z.enum(['duration-asc', 'duration-desc', 'bpm-asc', 'bpm-desc', 'title']).optional().describe('Legacy sort — accepted but no longer applied (feed order).'),
141
- limit: z.number().int().min(1).max(50).optional().describe('Results per page (max 40 now, default 20).'),
142
- offset: z.number().int().min(0).optional().describe('Pagination offset for loading more results.')
104
+ sort: z.enum(['duration-asc', 'duration-desc', 'bpm-asc', 'bpm-desc', 'title']).optional(),
105
+ limit: z.number().int().min(1).max(50).optional(),
106
+ offset: z.number().int().min(0).optional(),
107
+ },
108
+ async ({ sort, limit, offset }) => {
109
+ const params = new URLSearchParams();
110
+ if (sort) params.set('sort', sort);
111
+ if (limit != null) params.set('limit', String(limit));
112
+ if (offset != null) params.set('offset', String(offset));
113
+ const result = await client.get(`/v1/music-library/catalog?${params.toString()}`);
114
+ return tracksResult(ui, 'SYNCI Music Library', result.tracks || [], result.total);
143
115
  },
144
- async ({ limit, offset }) => {
145
- const { assets, total } = await stockMusicSearch(client, '', limit, offset);
146
- return musicResult(ui, null, assets, total, 'No tracks returned.');
147
- }
148
116
  );
149
117
 
150
- // ─── get_music_library_facets (adapter → stock categories) ─────
151
118
  server.tool(
152
119
  'get_music_library_facets',
153
- DEPRECATION_NOTE + 'List the music category/genre chips available in the stock library. The catalog matches semantically, so any natural-language vibe also works as a query.',
120
+ 'List SYNCI genres, moods, instruments, BPM, and duration filters.',
154
121
  {},
155
122
  async () => {
156
- let categories = [];
157
- try {
158
- const result = await client.get('/v1/stock/categories?mediaType=music');
159
- categories = (result.categories || []).map(c => c.name || c.providerParam).filter(Boolean);
160
- } catch (_) { /* categories are best-effort */ }
161
- return {
162
- content: [{
163
- type: 'text',
164
- text: JSON.stringify({
165
- genres: categories,
166
- moods: [],
167
- instruments: [],
168
- bpmRange: null,
169
- durationRange: null,
170
- _note: 'Semantic search: any natural-language mood/vibe works as a query — exact facet values are no longer required.'
171
- }, null, 2)
172
- }]
173
- };
174
- }
123
+ const result = await client.get('/v1/music-library/facets');
124
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
125
+ },
175
126
  );
176
127
 
177
- // ─── get_music_track_audio (adapter → stock asset) ──────────────
178
128
  server.tool(
179
129
  'get_music_track_audio',
180
- DEPRECATION_NOTE + 'Get the downloadable audio URLs for a music track by id ("source:sourceId" from search results).',
130
+ 'Get watermarked preview URLs for a SYNCI track. These URLs are never licensed masters; call acquire_clean_music_track for final use.',
131
+ { track_id: z.string().min(1).max(64) },
132
+ async ({ track_id }) => {
133
+ const result = await client.get(`/v1/music-library/track/${encodeURIComponent(track_id)}/audio`);
134
+ return { content: [{ type: 'text', text: JSON.stringify({ ...result, preview_only: true }, null, 2) }] };
135
+ },
136
+ );
137
+
138
+ server.tool(
139
+ 'acquire_clean_music_track',
140
+ 'Acquire a clean, unwatermarked SYNCI MP3 or WAV for download or Adobe timeline use. This immediately consumes one SYNCI vendor credit with no confirmation dialog. Reuse request_id when retrying the same intended action.',
181
141
  {
182
- track_id: z.string().describe('Track id from search_music_library / browse_music_library (format "source:sourceId").')
142
+ track_id: z.string().min(1).max(64),
143
+ format: z.enum(['mp3', 'wav']).optional().describe('Default mp3. Use wav only when the search result reports hqAvailable=true.'),
144
+ purpose: z.enum(['download', 'timeline']).optional(),
145
+ request_id: z.string().regex(/^[A-Za-z0-9_-]{8,80}$/).describe('Required idempotency key. Reuse it for retries of the same action.'),
146
+ project_id: z.string().optional(),
147
+ },
148
+ async ({ track_id, format = 'mp3', purpose = 'download', request_id, project_id }) => {
149
+ const requestId = request_id;
150
+ const result = await client.post(`/v1/music-library/clean/${encodeURIComponent(track_id)}`, {
151
+ format,
152
+ purpose,
153
+ requestId,
154
+ projectId: project_id,
155
+ });
156
+ return cleanResult(result, requestId);
183
157
  },
184
- async ({ track_id }) => {
185
- const { source, id } = parseTrackId(track_id);
186
- // mediaType hint required for sources that share ids across types (kolbo-ai).
187
- const result = await client.get(`/v1/stock/asset/${encodeURIComponent(source)}/${encodeURIComponent(id)}?mediaType=music`);
188
- const a = result.asset || result;
189
- const urls = {};
190
- (a.downloadVariants || []).forEach(v => { if (v.url) urls[v.label || 'audio'] = v.url; });
191
- return {
192
- content: [{
193
- type: 'text',
194
- text: JSON.stringify({ id: track_id, title: a.title, urls }, null, 2)
195
- }]
196
- };
197
- }
198
158
  );
199
159
 
200
- // ─── get_music_track_related (graceful stub — no stock equivalent) ─
201
160
  server.tool(
202
- 'get_music_track_related',
203
- DEPRECATION_NOTE + 'Stems/alternate versions are not exposed by the unified stock library. Returns an empty set with guidance.',
161
+ 'import_music_track_to_library',
162
+ 'Acquire one clean SYNCI file and copy it into the Kolbo media library. This immediately consumes one SYNCI vendor credit unless the track is already in the library. Defaults to clean MP3.',
204
163
  {
205
- track_id: z.string().describe('The master track id.')
164
+ track_id: z.string().min(1).max(64),
165
+ format: z.enum(['mp3', 'wav']).optional(),
166
+ request_id: z.string().regex(/^[A-Za-z0-9_-]{8,80}$/).describe('Required idempotency key; reuse it for retries.'),
167
+ project_id: z.string().optional(),
168
+ track: z.record(z.string(), z.unknown()).optional().describe('Optional track snapshot from search_music_library.'),
169
+ },
170
+ async ({ track_id, format = 'mp3', request_id, project_id, track }) => {
171
+ const requestId = request_id;
172
+ const result = await client.post('/v1/music-library/import', {
173
+ trackId: track_id,
174
+ format,
175
+ requestId,
176
+ projectId: project_id,
177
+ track,
178
+ });
179
+ return { content: [{ type: 'text', text: JSON.stringify({ ...result, requestId }, null, 2) }] };
180
+ },
181
+ );
182
+
183
+ server.tool(
184
+ 'get_music_track_related',
185
+ 'Get SYNCI stems and alternate versions metadata. Purchasing stems or alternate versions is not supported.',
186
+ { track_id: z.string().min(1).max(64) },
187
+ async ({ track_id }) => {
188
+ const result = await client.get(`/v1/music-library/track/${encodeURIComponent(track_id)}/related`);
189
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
206
190
  },
207
- async ({ track_id }) => ({
208
- content: [{
209
- type: 'text',
210
- text: JSON.stringify({
211
- stems: [], versions: [],
212
- _note: `Stems/alternate versions are not available via the stock library. Use get_music_track_audio ("${track_id}") for the downloadable variants, or generate_music to compose a custom track.`
213
- }, null, 2)
214
- }]
215
- })
216
191
  );
217
192
 
218
- // ─── get_music_track_lyrics (graceful stub — no stock equivalent) ─
219
193
  server.tool(
220
194
  'get_music_track_lyrics',
221
- DEPRECATION_NOTE + 'Lyrics metadata is not exposed by the unified stock library. Returns hasLyrics: false with guidance.',
222
- {
223
- track_id: z.string().describe('The track id.')
195
+ 'Get SYNCI lyrics metadata for a track.',
196
+ { track_id: z.string().min(1).max(64) },
197
+ async ({ track_id }) => {
198
+ const result = await client.get(`/v1/music-library/track/${encodeURIComponent(track_id)}/lyrics`);
199
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
224
200
  },
225
- async () => ({
226
- content: [{
227
- type: 'text',
228
- text: JSON.stringify({
229
- hasLyrics: false, lyrics: null, lyricalTheme: null, explicit: null,
230
- _note: 'Lyrics metadata is not available via the stock library. For a song with specific lyrics, use generate_music with the lyrics field.'
231
- }, null, 2)
232
- }]
233
- })
234
201
  );
235
202
  }
236
203
 
@@ -4,6 +4,7 @@
4
4
  * new OPTIONAL args only. Full rules: ../index.js top-of-file and CLAUDE.md. */
5
5
 
6
6
  const { z } = require('zod');
7
+ const { buildProjectUrl } = require('./_shared');
7
8
 
8
9
  function registerProjectTools(server, client) {
9
10
  // ─── list_projects ─────────────────────────────────────────
@@ -17,7 +18,8 @@ function registerProjectTools(server, client) {
17
18
  id: p.id,
18
19
  name: p.name,
19
20
  role: p.role,
20
- is_default: !!p.is_default
21
+ is_default: !!p.is_default,
22
+ open_url: buildProjectUrl(p.id, { is_default: !!p.is_default })
21
23
  }));
22
24
  return {
23
25
  content: [{
@@ -25,7 +27,7 @@ function registerProjectTools(server, client) {
25
27
  text: JSON.stringify({
26
28
  projects,
27
29
  count: projects.length,
28
- _hint: 'Pass the chosen `id` as `project_id` on any generate_* tool to drop the generation into that project. Omit project_id to use the project flagged is_default:true.'
30
+ _hint: 'Pass the chosen `id` as `project_id` on any generate_* tool to drop the generation into that project. Omit project_id to use the project flagged is_default:true. `open_url` opens that project\'s media in the web app (share it with the user).'
29
31
  }, null, 2)
30
32
  }]
31
33
  };
@@ -68,7 +70,8 @@ function registerProjectTools(server, client) {
68
70
  const body = { name };
69
71
  if (description) body.description = description;
70
72
  const result = await client.post('/v1/projects', body);
71
- return { content: [{ type: 'text', text: JSON.stringify({ project: result.project, _hint: 'Pass this id as project_id on every subsequent call for this work.' }, null, 2) }] };
73
+ const open_url = buildProjectUrl(result.project && result.project.id, { is_default: !!(result.project && result.project.is_default) });
74
+ return { content: [{ type: 'text', text: JSON.stringify({ project: result.project, open_url, _hint: 'Pass this id as project_id on every subsequent call for this work. `open_url` opens the project in the web app — share it with the user.' }, null, 2) }] };
72
75
  }
73
76
  );
74
77
 
@@ -181,7 +181,7 @@ function registerStockLibraryTools(server, client, options = {}) {
181
181
  // ─── import_stock_asset ───────────────────────────────────────
182
182
  server.tool(
183
183
  'import_stock_asset',
184
- "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. Returns the created media library item. Works for Kolbo SFX (source='kolbo-ai', mediaType='sfx') and external visual/audio sources. Licensed Music (source='music') is not importable here (use the music-library tools).",
184
+ "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. 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.",
185
185
  {
186
186
  source: z.enum(['kolbo-ai', 'pexels', 'pixabay', 'sketchfab', 'freesound']).describe('The asset source.'),
187
187
  id: z.string().describe('The provider asset id (sourceId).'),