@kolbo/mcp 1.105.0 → 1.106.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.105.0",
3
+ "version": "1.106.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": {
@@ -55,6 +55,8 @@ function pageSize() {
55
55
  var audio = items.filter(isAudio).length;
56
56
  return audio > items.length / 2 ? 6 : 12;
57
57
  }
58
+ // Items fetched per Next when the widget pages the server itself.
59
+ var FETCH_BATCH = 24;
58
60
  function pageCount() { return Math.max(1, Math.ceil(((state && state.items) || []).length / pageSize())); }
59
61
  // Only a tool that told us HOW to page can go past what is already loaded.
60
62
  // Every other grid used to render a Load more that did nothing.
@@ -250,6 +252,10 @@ function fetchNextPage(btn) {
250
252
  // when it shipped next_args, send exactly that. The query+page shape is the
251
253
  // legacy fallback list_media has always used.
252
254
  var args = {};
255
+ // Set when the first page was too small to page with (the model asked
256
+ // list_media for 3 items): re-read from the start in a full batch instead of
257
+ // adding 3 more tiles per Next.
258
+ var replaceFrom = -1;
253
259
  if (state.next_args) {
254
260
  for (var nk in state.next_args) { if (state.next_args[nk] !== undefined && state.next_args[nk] !== null) args[nk] = state.next_args[nk]; }
255
261
  } else {
@@ -257,6 +263,15 @@ function fetchNextPage(btn) {
257
263
  for (var k in q) { if (q[k] !== undefined && q[k] !== null && q[k] !== '') args[k] = q[k]; }
258
264
  args.page = nextPage;
259
265
  if (state.page_size) args.page_size = state.page_size;
266
+ var loaded = (state.items || []).length;
267
+ if ((state.page_size || 0) < FETCH_BATCH && loaded < 200 - FETCH_BATCH) {
268
+ // One aligned batch: page 1 at a size that is a whole number of batches
269
+ // covering everything loaded plus one more batch. After this the batch
270
+ // size divides the loaded count, so later Nexts append normally.
271
+ args.page = 1;
272
+ args.page_size = Math.ceil((loaded + 1) / FETCH_BATCH) * FETCH_BATCH;
273
+ replaceFrom = loaded;
274
+ }
260
275
  }
261
276
 
262
277
  window.kolbo.callTool(state.page_tool, args).then(function (res) {
@@ -276,6 +291,18 @@ function fetchNextPage(btn) {
276
291
  render();
277
292
  return;
278
293
  }
294
+ if (replaceFrom >= 0) {
295
+ // Keep the view on the first tile the user has not seen yet.
296
+ state.items = more;
297
+ state.page = (args.page_size / FETCH_BATCH);
298
+ state.page_size = FETCH_BATCH;
299
+ state.next_args = undefined;
300
+ if (sc && sc.total != null && sc.total >= 0) state.total = sc.total;
301
+ state.has_next = sc && typeof sc.has_next === 'boolean' ? sc.has_next : undefined;
302
+ page = Math.floor(replaceFrom / pageSize());
303
+ render();
304
+ return;
305
+ }
279
306
  state.items = (state.items || []).concat(more);
280
307
  state.page = (sc && sc.page) || nextPage;
281
308
  // The next page's args come from THIS response; none means this was the last page.
package/src/index.js CHANGED
@@ -69,6 +69,7 @@ const { registerColorPaletteTools } = require('./tools/color_palettes');
69
69
  const { registerFontTools } = require('./tools/fonts');
70
70
  const { registerEditorTools } = require('./tools/editor');
71
71
  const { registerFlowTools } = require('./tools/flow');
72
+ const { registerTrendTools } = require('./tools/trends');
72
73
  const { registerMediaTools } = require('./tools/media');
73
74
  const { registerPresetTools } = require('./tools/presets');
74
75
  const { registerArtifactTools } = require('./tools/artifacts');
@@ -194,6 +195,7 @@ function createServer(opts = {}) {
194
195
  registerFontTools(server, client, { allowLocalFiles: opts.allowLocalFiles === true && !toolOptions.remote });
195
196
  registerEditorTools(server, client);
196
197
  registerFlowTools(server, client);
198
+ registerTrendTools(server, client);
197
199
  registerAnalyzeTools(server, client, toolOptions);
198
200
  registerMediaTools(server, client, toolOptions);
199
201
  registerPresetTools(server, client, toolOptions);
@@ -11,6 +11,7 @@
11
11
  const READ_ONLY = [
12
12
  'get_flow_schema', 'list_flow_sessions', 'get_flow_session', 'validate_flow_session',
13
13
  'estimate_flow_run', 'get_flow_run', 'list_flow_runs',
14
+ 'list_trends', 'get_trend', 'estimate_trend_run', 'get_trend_run', 'list_trend_runs',
14
15
  'get_download_status',
15
16
  'get_video_editor_schema', 'list_video_editor_sessions', 'get_video_editor_session',
16
17
  'list_fonts', 'get_font', 'get_font_upload_status',
@@ -78,6 +79,7 @@ const PRIVATE_WRITE = [
78
79
  const DESTRUCTIVE_WRITE = [
79
80
  'update_flow_session', 'undo_flow_edit', 'trash_flow_session',
80
81
  'run_flow_session', 'cancel_flow_run', 'retry_flow_run',
82
+ 'run_trend', 'cancel_trend_run',
81
83
  'cancel_download',
82
84
  'extend_music', 'cover_music',
83
85
  'update_video_editor_session',
@@ -1081,7 +1081,8 @@ function registerGenerateTools(server, client, options = {}) {
1081
1081
  {
1082
1082
  text: z.string().describe('The text to convert to speech'),
1083
1083
  voice: z.string().optional().describe('Voice ID or display name — MUST come from a `list_voices` result, never constructed. Google/Gemini ids in particular are not validated provider-side: an id that is not in the catalog is silently mapped to another voice (or a default one) and the audio comes back in a voice nobody asked for. Do not pattern-match a locale onto an id you saw for another language. Default: "Rachel"'),
1084
- model: z.string().optional().describe('Model identifier. Use list_models type="text_to_speech" to see options. Default: eleven_v3'),
1084
+ model: z.string().optional().describe('Model identifier. Use list_models type="text_to_speech" to see options. Default: eleven_v4 (ElevenLabs V4). ElevenLabs V4/V3 voices follow inline audio tags in the text: delivery ([whispers], [shouting], [sarcastic]), reactions ([laughs], [sighs], [clears throat]), pauses ([pause], [long pause]) and, on V4, free-form directions like [Low, steady voice, restrained urgency]. Tags are always English, even in Hebrew text. SSML such as <break> is NOT supported.'),
1085
+ turbo: z.boolean().optional().describe('ElevenLabs V4 voices ONLY. Turbo: faster generation at half the credits, slightly lower quality. Rejected (TURBO_UNSUPPORTED) for any voice not on ElevenLabs V4. Default false.'),
1085
1086
  language: z.string().optional().describe('Language / accent code (e.g., "en-US", "he-IL", "es-ES"). For Google/Gemini voices this is the Accent control (does not translate the text). Default: "en-US"'),
1086
1087
  // ── Expressive style / emotion (provider-specific) ──
1087
1088
  // Google/Gemini: prefer a named preset id from the Kolbo UI picker; free-form
@@ -1119,7 +1120,7 @@ function registerGenerateTools(server, client, options = {}) {
1119
1120
  project_id: projectIdField,
1120
1121
  session_id: sessionIdField
1121
1122
  },
1122
- async ({ text, voice, model, language, style_instructions_preset_id, style_instructions, style_instructions_label, selected_style, emotion, speaking_speed, similarity_boost, style, use_speaker_boost, variance, tempo, promptBoost, seed, accentControl, voiceTitle, minimax_pitch, minimax_vol, minimax_intensity, minimax_timbre, project_id, session_id }) => {
1123
+ async ({ text, voice, model, turbo, language, style_instructions_preset_id, style_instructions, style_instructions_label, selected_style, emotion, speaking_speed, similarity_boost, style, use_speaker_boost, variance, tempo, promptBoost, seed, accentControl, voiceTitle, minimax_pitch, minimax_vol, minimax_intensity, minimax_timbre, project_id, session_id }) => {
1123
1124
  model = await canonicalModelId(client, model, 'text_to_speech'); // lenient id resolution ("z-image" → "z-image/turbo")
1124
1125
  // Resolve the requested voice against the REAL catalog (cached) so the card
1125
1126
  // can show its display name + portrait instead of a raw id, and so an id
@@ -1132,7 +1133,7 @@ function registerGenerateTools(server, client, options = {}) {
1132
1133
  ? `Voice "${voice}" is not in the Kolbo voice catalog. Call list_voices and pass a voice_id it returns — an unrecognised id is NOT rejected, it is silently mapped to a different voice, so the audio will not be the voice you named.`
1133
1134
  : null;
1134
1135
  const gen = await client.post('/v1/generate/speech', {
1135
- text, voice, model, language,
1136
+ text, voice, model, turbo, language,
1136
1137
  style_instructions_preset_id, style_instructions, style_instructions_label,
1137
1138
  selected_style, emotion, speaking_speed,
1138
1139
  similarity_boost, style, use_speaker_boost,
@@ -0,0 +1,189 @@
1
+ /* ⛔ BACKWARD COMPATIBILITY: Tool names and arg names below are a PUBLIC
2
+ * CONTRACT. Never rename, remove, or break an existing tool/arg — old cached
3
+ * `npx @kolbo/mcp` installs in the wild will break silently. Add new tools or
4
+ * new OPTIONAL args only. Full rules: ../index.js top-of-file and CLAUDE.md. */
5
+
6
+ // Trends: plug-and-play generations with a hidden recipe. The caller fills the
7
+ // trend's declared inputs (photos, a clip, a name) and gets ONE final result;
8
+ // the steps in between stay private. Motion Library presets are served as
9
+ // trends too (slug "motion/<name>"). Contract: kolbo-api src/modules/trends.
10
+
11
+ const { randomUUID } = require('crypto');
12
+ const { z } = require('zod');
13
+ const { UI, uiResult } = require('../apps');
14
+
15
+ const slugField = z.string().min(1).max(200).regex(/^[\w-]+(\/[\w-]+)?$/)
16
+ .describe('Trend slug from list_trends, e.g. "vanish" or "motion/kolbo-urban-dance-glide".');
17
+ const inputsField = z.record(z.string(), z.string().max(2048))
18
+ .describe('Values for the trend\'s declared inputs, keyed by input `key` from get_trend. Media inputs take an https URL (upload local files with upload_media / create_upload_ticket first). Text inputs take plain text.');
19
+ const resolutionField = z.enum(['480p', '720p', '1080p']).optional()
20
+ .describe('Render resolution for video trends (see resolutions in get_trend). Default: the trend\'s own. Higher costs more; quote it with estimate_trend_run.');
21
+ const projectField = z.string().min(1).max(128).optional()
22
+ .describe('Project to run in. Omit for the default "API Generations" project.');
23
+
24
+ // Keep the slash between "motion" and the preset name; encode each segment.
25
+ const slugPath = slug => String(slug).split('/').map(encodeURIComponent).join('/');
26
+ const unwrap = r => (r && r.status === true && r.data !== undefined ? r.data : r);
27
+ const text = value => ({ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] });
28
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
29
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
30
+
31
+ function trendTile(t) {
32
+ const example = (t.examples || [])[0] || {};
33
+ const poster = t.cover?.image || example.poster || t.source_poster;
34
+ return {
35
+ id: t.slug,
36
+ title: t.name,
37
+ subtitle: t.estimated_credits ? `~${t.estimated_credits} credits` : undefined,
38
+ thumbnail: poster,
39
+ url: t.cover?.video || example.output || poster,
40
+ media_type: t.output_type === 'image' ? 'image' : 'video',
41
+ use_hint: 'Run the "{TITLE}" trend (slug: {ID}) with my media.',
42
+ };
43
+ }
44
+
45
+ function runTile(r) {
46
+ return {
47
+ id: r.run_id,
48
+ title: r.trend_name || r.trend,
49
+ subtitle: r.status === 'completed' ? 'Done' : r.status === 'failed' ? 'Failed' : `${r.status} · ${r.progress || 0}%`,
50
+ thumbnail: (r.output_type === 'image' && r.output?.url) || r.trend_cover,
51
+ url: r.output?.url || r.trend_cover,
52
+ media_type: r.output?.type === 'image' || r.output_type === 'image' ? 'image' : 'video',
53
+ use_hint: 'Show trend run {ID}.',
54
+ };
55
+ }
56
+
57
+ // Poll one run until it finishes or the window closes. A closed window is NOT a
58
+ // failure: the run keeps going server-side and get_trend_run picks it up.
59
+ async function waitForRun(client, runId, maxMs) {
60
+ const deadline = Date.now() + maxMs;
61
+ let run;
62
+ for (;;) {
63
+ run = unwrap(await client.get(`/v1/trends/runs/${encodeURIComponent(runId)}`));
64
+ if (TERMINAL.has(run.status) || Date.now() >= deadline) return run;
65
+ await sleep(5000);
66
+ }
67
+ }
68
+
69
+ function runResult(run) {
70
+ if (!TERMINAL.has(run.status)) {
71
+ run._hint = `Still running (this is normal — video trends take a few minutes). Call get_trend_run with run_id="${run.run_id}" and wait=true. Do NOT start the trend again.`;
72
+ }
73
+ return text(run);
74
+ }
75
+
76
+ function registerTrendTools(server, client) {
77
+ server.tool(
78
+ 'list_trends',
79
+ 'Browse Kolbo Trends: one-click generations with a hidden recipe (e.g. put yourself in a viral clip, turn a photo into an action figure). Includes Motion Library presets (slugs starting "motion/") that restyle or recast a video. Each trend lists the inputs it needs, its output type and an estimated credit cost. Run one with run_trend.',
80
+ {
81
+ family: z.enum(['viral', 'image', 'swap', 'product', 'motion']).optional().describe('Filter by family. "motion" = Motion Library presets only.'),
82
+ search: z.string().max(100).optional().describe('Case-insensitive match on name, description and tags.'),
83
+ include_motion: z.boolean().optional().describe('Include Motion Library presets when no family is set. Default true.'),
84
+ },
85
+ async ({ family, search, include_motion } = {}) => {
86
+ const wantMotion = family === 'motion' || (!family && include_motion !== false);
87
+ const [base, motion] = await Promise.all([
88
+ family === 'motion' ? null : client.get('/v1/trends' + (family ? `?family=${encodeURIComponent(family)}` : '')),
89
+ wantMotion ? client.get('/v1/trends/motion') : null,
90
+ ]);
91
+ const baseData = unwrap(base) || {};
92
+ const q = (search || '').trim().toLowerCase();
93
+ const trends = [...(baseData.trends || []), ...((unwrap(motion) || {}).trends || [])]
94
+ .filter(t => !q || [t.name, t.description, ...(t.tags || [])].some(s => s && String(s).toLowerCase().includes(q)));
95
+ const summary = trends.map(t => ({
96
+ slug: t.slug, name: t.name, description: t.description, family: t.family, output_type: t.output_type,
97
+ estimated_credits: t.estimated_credits, free_trial: t.free_trial, badges: t.badges,
98
+ inputs: (t.inputs || []).map(i => ({ key: i.key, kind: i.kind, role: i.role, label: i.label, required: i.required })),
99
+ }));
100
+ return uiResult(UI.mediaGrid, JSON.stringify({ trends: summary, count: summary.length, free_trials_left: baseData.free_trials_left ?? null }, null, 2), {
101
+ widget: 'media-grid',
102
+ title: family === 'motion' ? 'Motion Library' : 'Trends',
103
+ items: trends.slice(0, 300).map(trendTile),
104
+ total: trends.length,
105
+ has_more: trends.length > 300,
106
+ });
107
+ }
108
+ );
109
+
110
+ server.tool(
111
+ 'get_trend',
112
+ 'Get one trend: description, the exact inputs it needs (key, kind: image/video/audio/text/choice, role: what the input is (character/pet/product/location/video/text) - a character input also takes a Visual DNA image URL, required, options), output type/aspect ratio, estimated credits, example results and whether a free trial is available.',
113
+ { slug: slugField },
114
+ async ({ slug }) => text(unwrap(await client.get(`/v1/trends/${slugPath(slug)}`)))
115
+ );
116
+
117
+ server.tool(
118
+ 'estimate_trend_run',
119
+ 'Quote a trend run before spending: returns total_credits for these exact inputs (video trends are priced by the source clip length). Free — no credits spent.',
120
+ { slug: slugField, inputs: inputsField, project_id: projectField, resolution: resolutionField },
121
+ async ({ slug, inputs, project_id, resolution }) => text(unwrap(await client.post(`/v1/trends/${slugPath(slug)}/estimate`, { inputs, ...(project_id && { project_id }), ...(resolution && { resolution }) })))
122
+ );
123
+
124
+ server.tool(
125
+ 'run_trend',
126
+ 'Run a trend. SPENDS CREDITS (quote with estimate_trend_run first and confirm with the user unless they already agreed). Returns one final result; intermediate steps are private. Waits up to wait_seconds for the result, then returns the run so you can keep checking with get_trend_run. Reuse the same idempotency_key after an uncertain response — a new key starts (and charges) a new run.',
127
+ {
128
+ slug: slugField,
129
+ inputs: inputsField,
130
+ project_id: projectField,
131
+ resolution: resolutionField,
132
+ max_credits: z.number().finite().min(0).optional().describe('Credit ceiling for this run. Default: the trend\'s own ceiling.'),
133
+ free_trial: z.boolean().optional().describe('Use one of the account\'s free trend runs (only trends with free_trial: true).'),
134
+ idempotency_key: z.string().regex(/^[A-Za-z0-9_.:-]{8,100}$/).optional().describe('8–100 chars. Omit to have one generated; pass the SAME key to retry an uncertain request.'),
135
+ wait: z.boolean().optional().describe('Wait for the result. Default true.'),
136
+ wait_seconds: z.number().int().min(0).max(170).optional().describe('How long to wait before returning. Default 150.'),
137
+ },
138
+ async ({ slug, inputs, project_id, resolution, max_credits, free_trial, idempotency_key, wait, wait_seconds }) => {
139
+ const body = { inputs, idempotency_key: idempotency_key || randomUUID(), ...(project_id && { project_id }), ...(resolution && { resolution }), ...(max_credits !== undefined && { max_credits }), ...(free_trial && { free_trial: true }) };
140
+ const started = unwrap(await client.post(`/v1/trends/${slugPath(slug)}/runs`, body));
141
+ started.idempotency_key = body.idempotency_key;
142
+ if (wait === false || TERMINAL.has(started.status)) return runResult(started);
143
+ const run = await waitForRun(client, started.run_id, (wait_seconds ?? 150) * 1000);
144
+ return runResult({ ...run, idempotency_key: body.idempotency_key });
145
+ }
146
+ );
147
+
148
+ server.tool(
149
+ 'get_trend_run',
150
+ 'Check a trend run: status (running/completed/failed/cancelled), progress %, and the final output URL when done. Pass wait=true to block until it finishes (up to ~2.5 minutes) instead of polling in a loop.',
151
+ {
152
+ run_id: z.string().min(1).max(128),
153
+ wait: z.boolean().optional().describe('Block until the run finishes or ~150s pass.'),
154
+ },
155
+ async ({ run_id, wait }) => runResult(wait ? await waitForRun(client, run_id, 150000) : unwrap(await client.get(`/v1/trends/runs/${encodeURIComponent(run_id)}`)))
156
+ );
157
+
158
+ server.tool(
159
+ 'list_trend_runs',
160
+ 'List the user\'s recent trend runs in a project (newest first) with status, progress and final output.',
161
+ {
162
+ project_id: projectField,
163
+ limit: z.number().int().min(1).max(100).optional().describe('Default 30.'),
164
+ },
165
+ async ({ project_id, limit } = {}) => {
166
+ const params = new URLSearchParams();
167
+ if (project_id) params.set('project_id', project_id);
168
+ if (limit) params.set('limit', String(limit));
169
+ const qs = params.toString();
170
+ const runs = (unwrap(await client.get('/v1/trends/runs' + (qs ? `?${qs}` : ''))) || {}).runs || [];
171
+ return uiResult(UI.mediaGrid, JSON.stringify({ runs, count: runs.length }, null, 2), {
172
+ widget: 'media-grid',
173
+ title: 'Trend runs',
174
+ items: runs.map(runTile),
175
+ total: runs.length,
176
+ has_more: false,
177
+ });
178
+ }
179
+ );
180
+
181
+ server.tool(
182
+ 'cancel_trend_run',
183
+ 'Cancel a running trend run and release its unspent credits. Free-trial runs cannot be cancelled.',
184
+ { run_id: z.string().min(1).max(128) },
185
+ async ({ run_id }) => text(unwrap(await client.post(`/v1/trends/runs/${encodeURIComponent(run_id)}/cancel`, {})))
186
+ );
187
+ }
188
+
189
+ module.exports = { registerTrendTools };