@kolbo/mcp 1.48.0 → 1.50.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.
@@ -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).'),