@kolbo/mcp 1.14.0 → 1.15.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,20 +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
- ## Recommended install: the Kolbo Claude Code plugin
8
-
9
- The easiest way to use this MCP from Claude Code is the official **Kolbo plugin**, which bundles this server + the routing skill + a first-run API-key prompt — no `settings.json` editing required:
10
-
11
- ```bash
12
- claude plugin marketplace add Zoharvan12/kolbo-claude-plugin
13
- claude plugin install kolbo@kolbo
14
- ```
15
-
16
- You'll be prompted for your API key once and it's stored in your OS keychain. The plugin lives in its own tiny repo at [`kolbo-claude-plugin`](https://github.com/Zoharvan12/kolbo-claude-plugin); the routing skill it ships is auto-synced from the canonical source in [`kolbo-code`](https://github.com/Zoharvan12/kolbo-code), so the Claude Code plugin and the Kolbo Code CLI always carry the same skill.
17
-
18
- Continue below if you'd rather wire the MCP in by hand (Claude Desktop, Cursor, custom setups).
19
-
20
- ## Manual setup
7
+ ## Quick Setup
21
8
 
22
9
  ### 1. Get an API Key
23
10
 
@@ -128,11 +115,6 @@ Every generation tool also accepts an optional `resolution` arg. Images use `"1K
128
115
  | `move_folder_contents` | Move every item in a folder to a project |
129
116
  | `get_media_stats` | Counts + storage bytes per type (optionally per project) |
130
117
 
131
- **Artifacts**
132
- | Tool | Description |
133
- |------|-------------|
134
- | `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). |
135
-
136
118
  **Discovery & Account**
137
119
  | Tool | Description |
138
120
  |------|-------------|
package/bin/kolbo-mcp.js CHANGED
@@ -1,2 +1,2 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  require('../src/index.js');
package/package.json CHANGED
@@ -1,55 +1,56 @@
1
- {
2
- "name": "@kolbo/mcp",
3
- "version": "1.14.0",
4
- "description": "Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code",
5
- "main": "src/index.js",
6
- "bin": {
7
- "kolbo-mcp": "./bin/kolbo-mcp.js"
8
- },
9
- "scripts": {
10
- "start": "node src/index.js",
11
- "smoke": "node scripts/smoke.js",
12
- "check-parity": "node scripts/check-parity.js",
13
- "prepublishOnly": "node scripts/smoke.js && node scripts/check-parity.js"
14
- },
15
- "keywords": [
16
- "kolbo",
17
- "mcp",
18
- "ai",
19
- "image-generation",
20
- "video-generation",
21
- "music-generation",
22
- "text-to-speech",
23
- "claude-code",
24
- "claude-desktop",
25
- "model-context-protocol"
26
- ],
27
- "license": "MIT",
28
- "repository": {
29
- "type": "git",
30
- "url": "https://github.com/Zoharvan12/kolbo-mcp"
31
- },
32
- "homepage": "https://docs.kolbo.ai/developer-api/claude-code-skill",
33
- "author": "Kolbo AI <support@kolbo.ai>",
34
- "publishConfig": {
35
- "access": "public"
36
- },
37
- "files": [
38
- "src/",
39
- "bin/",
40
- "README.md"
41
- ],
42
- "dependencies": {
43
- "@modelcontextprotocol/sdk": "1.29.0",
44
- "form-data": "^4.0.5",
45
- "zod": "^3.25.0"
46
- },
47
- "overrides": {
48
- "hono": "^4.12.12",
49
- "@hono/node-server": "^1.19.13",
50
- "path-to-regexp": "^8.4.2"
51
- },
52
- "engines": {
53
- "node": ">=18.0.0"
54
- }
55
- }
1
+ {
2
+ "name": "@kolbo/mcp",
3
+ "version": "1.15.0",
4
+ "description": "Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "kolbo-mcp": "./bin/kolbo-mcp.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node src/index.js",
11
+ "smoke": "node scripts/smoke.js",
12
+ "check-parity": "node scripts/check-parity.js",
13
+ "prepublishOnly": "node scripts/smoke.js && node scripts/check-parity.js"
14
+ },
15
+ "keywords": [
16
+ "kolbo",
17
+ "mcp",
18
+ "ai",
19
+ "image-generation",
20
+ "video-generation",
21
+ "music-generation",
22
+ "text-to-speech",
23
+ "claude-code",
24
+ "claude-desktop",
25
+ "model-context-protocol"
26
+ ],
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/Zoharvan12/kolbo-code",
31
+ "directory": "packages/kolbo-mcp"
32
+ },
33
+ "homepage": "https://docs.kolbo.ai/developer-api/claude-code-skill",
34
+ "author": "Kolbo AI <support@kolbo.ai>",
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "files": [
39
+ "src/",
40
+ "bin/",
41
+ "README.md"
42
+ ],
43
+ "dependencies": {
44
+ "@modelcontextprotocol/sdk": "1.29.0",
45
+ "form-data": "^4.0.5",
46
+ "zod": "^3.25.0"
47
+ },
48
+ "overrides": {
49
+ "hono": "^4.12.12",
50
+ "@hono/node-server": "^1.19.13",
51
+ "path-to-regexp": "^8.4.2"
52
+ },
53
+ "engines": {
54
+ "node": ">=18.0.0"
55
+ }
56
+ }
package/src/client.js CHANGED
@@ -139,6 +139,63 @@ function readCliAuthKey() {
139
139
  return null;
140
140
  }
141
141
 
142
+ // ---------------------------------------------------------------------------
143
+ // Burst control
144
+ // ---------------------------------------------------------------------------
145
+ // Caps in-flight requests so multi-step tool calls (file upload + generate +
146
+ // poll status) don't fan out past the kolbo-api global rate limit. The SDK
147
+ // tier on the server is 5000 req/min per API key; 6 in-flight × typical 2s
148
+ // latency keeps us at ~180/min — plenty of headroom.
149
+ const MAX_CONCURRENT = 6;
150
+ let inFlight = 0;
151
+ const waiting = [];
152
+
153
+ function acquireSlot() {
154
+ if (inFlight < MAX_CONCURRENT) {
155
+ inFlight++;
156
+ return Promise.resolve();
157
+ }
158
+ return new Promise((resolve) => waiting.push(resolve));
159
+ }
160
+
161
+ function releaseSlot() {
162
+ if (waiting.length > 0) {
163
+ waiting.shift()();
164
+ } else {
165
+ inFlight = Math.max(0, inFlight - 1);
166
+ }
167
+ }
168
+
169
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
170
+
171
+ /**
172
+ * Parse retry hint from a 429 response. Honors (in order):
173
+ * - body.retryAfter (kolbo-api shape)
174
+ * - Retry-After header (RFC, seconds)
175
+ * - RateLimit-Reset header (seconds until reset)
176
+ * Falls back to 5s. Caps at 60s so we don't hang the tool call forever.
177
+ */
178
+ function parseRetryAfter(response, body) {
179
+ let seconds = null;
180
+ if (body && typeof body.retryAfter === 'number') seconds = body.retryAfter;
181
+ if (seconds == null) {
182
+ const ra = response.headers.get('retry-after');
183
+ if (ra) {
184
+ const n = Number(ra);
185
+ if (Number.isFinite(n)) seconds = n;
186
+ }
187
+ }
188
+ if (seconds == null) {
189
+ const reset = response.headers.get('ratelimit-reset');
190
+ if (reset) {
191
+ const n = Number(reset);
192
+ if (Number.isFinite(n)) seconds = n;
193
+ }
194
+ }
195
+ if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) seconds = 5;
196
+ return Math.min(seconds, 60) * 1000;
197
+ }
198
+
142
199
  // ---------------------------------------------------------------------------
143
200
  // HTTP client
144
201
  // ---------------------------------------------------------------------------
@@ -189,13 +246,31 @@ class KolboClient {
189
246
  }
190
247
 
191
248
  async request(method, reqPath, body = null) {
192
- const result = await this._doRequest(method, reqPath, body);
249
+ await acquireSlot();
250
+ try {
251
+ const result = await this._doRequestWithRetry(method, reqPath, body);
193
252
 
194
- // On 401, try re-reading auth store and retry once
195
- if (result._status === 401 && this._tryRefreshKey()) {
196
- return this._doRequest(method, reqPath, body);
253
+ // On 401, try re-reading auth store and retry once
254
+ if (result._status === 401 && this._tryRefreshKey()) {
255
+ return this._doRequestWithRetry(method, reqPath, body);
256
+ }
257
+ return result;
258
+ } finally {
259
+ releaseSlot();
260
+ }
261
+ }
262
+
263
+ async _doRequestWithRetry(method, reqPath, body = null) {
264
+ try {
265
+ return await this._doRequest(method, reqPath, body);
266
+ } catch (err) {
267
+ if (err instanceof KolboApiError && err.status === 429) {
268
+ const delay = err.data?._kolboRetryAfterMs ?? 5000;
269
+ await sleep(delay);
270
+ return this._doRequest(method, reqPath, body);
271
+ }
272
+ throw err;
197
273
  }
198
- return result;
199
274
  }
200
275
 
201
276
  async _doRequest(method, reqPath, body = null) {
@@ -232,6 +307,13 @@ class KolboClient {
232
307
  });
233
308
  }
234
309
 
310
+ if (response.status === 429) {
311
+ throw new KolboApiError('Rate limited by Kolbo API. Retrying after backoff.', {
312
+ status: 429,
313
+ data: { ...(data || {}), _kolboRetryAfterMs: parseRetryAfter(response, data) },
314
+ });
315
+ }
316
+
235
317
  if (!response.ok || data.success === false) {
236
318
  const message = data.error || data.message || `API error: ${response.status}`;
237
319
  const code = data.code || null;
@@ -278,11 +360,29 @@ class KolboClient {
278
360
  }
279
361
 
280
362
  async postMultipart(reqPath, formData) {
281
- const result = await this._doMultipart(reqPath, formData);
282
- if (result._status === 401 && this._tryRefreshKey()) {
283
- return this._doMultipart(reqPath, formData);
363
+ await acquireSlot();
364
+ try {
365
+ const result = await this._doMultipartWithRetry(reqPath, formData);
366
+ if (result._status === 401 && this._tryRefreshKey()) {
367
+ return this._doMultipartWithRetry(reqPath, formData);
368
+ }
369
+ return result;
370
+ } finally {
371
+ releaseSlot();
372
+ }
373
+ }
374
+
375
+ async _doMultipartWithRetry(reqPath, formData) {
376
+ try {
377
+ return await this._doMultipart(reqPath, formData);
378
+ } catch (err) {
379
+ if (err instanceof KolboApiError && err.status === 429) {
380
+ const delay = err.data?._kolboRetryAfterMs ?? 5000;
381
+ await sleep(delay);
382
+ return this._doMultipart(reqPath, formData);
383
+ }
384
+ throw err;
284
385
  }
285
- return result;
286
386
  }
287
387
 
288
388
  async _doMultipart(reqPath, formData) {
@@ -319,6 +419,13 @@ class KolboClient {
319
419
  });
320
420
  }
321
421
 
422
+ if (response.status === 429) {
423
+ throw new KolboApiError('Rate limited by Kolbo API. Retrying after backoff.', {
424
+ status: 429,
425
+ data: { ...(data || {}), _kolboRetryAfterMs: parseRetryAfter(response, data) },
426
+ });
427
+ }
428
+
322
429
  if (!response.ok || data.success === false) {
323
430
  const message = data.error || data.message || `API error: ${response.status}`;
324
431
  const code = data.code || null;
@@ -9,29 +9,23 @@ function registerArtifactTools(server, client) {
9
9
  // ─── publish_html_artifact ─────────────────────────────────────
10
10
  server.tool(
11
11
  'publish_html_artifact',
12
- 'Publish an HTML page (or SVG / Mermaid diagram) to kolbo.ai and return a public shareable URL. Use this when the user explicitly asks to share, publish, or deploy a built artifact so they can send the URL to someone. The content is hosted at https://sites.kolbo.ai/<slug>; the page is served with restrictive CSP (no fetch/XHR/form-action) so it cannot exfiltrate data. Identical content uploaded twice returns the same URL (server dedup). To update a previously-published page in place (keeping the same URL), pass the `share_token` returned from the prior publish — the old content is preserved in version history.',
12
+ 'Publish an HTML page (or SVG / Mermaid diagram) to kolbo.ai and return a public shareable URL. Use this when the user explicitly asks to share, publish, or deploy a built artifact so they can send the URL to someone. The content is hosted at https://sites.kolbo.ai/<slug>; the page is served with restrictive CSP (no fetch/XHR/form-action) so it cannot exfiltrate data. Identical content uploaded twice returns the same URL (server dedup).',
13
13
  {
14
14
  title: z.string().describe('Human-friendly title for the page (also used to generate the SEO slug). Keep under ~60 chars.'),
15
15
  content: z.string().describe('The raw artifact body. For type="html" this is a full HTML document (DOCTYPE + html/head/body). For "svg" it is an <svg> document. For "mermaid" it is the Mermaid source text.'),
16
16
  type: z.enum(['html', 'svg', 'mermaid']).optional().describe('Artifact type. Default: "html".'),
17
17
  allow_js: z.boolean().optional().describe('Allow inline <script> execution on the published page. Default: false. Required for Tailwind JIT, Chart.js, Three.js, React-from-CDN etc.'),
18
- share_token: z.string().optional().describe('Optional. Pass the `shareToken` returned from a previous publish to update that artifact in place. The public URL stays the same and the old content is moved into version history. Omit this on the first publish.'),
19
18
  },
20
- async ({ title, content, type, allow_js, share_token }) => {
19
+ async ({ title, content, type, allow_js }) => {
21
20
  if (!title || !title.trim()) throw new Error('title is required');
22
21
  if (typeof content !== 'string' || !content.length) throw new Error('content is required');
23
22
 
24
- const body = {
23
+ const result = await client.post('/artifact/quick-share', {
25
24
  title: title.trim(),
26
25
  content,
27
26
  type: type || 'html',
28
27
  allowJs: allow_js === true,
29
- };
30
- if (typeof share_token === 'string' && share_token.trim()) {
31
- body.shareToken = share_token.trim();
32
- }
33
-
34
- const result = await client.post('/artifact/quick-share', body);
28
+ });
35
29
 
36
30
  const artifact = result?.data || {};
37
31
  const slug = artifact.shareableSlug || artifact.shareToken;
@@ -55,7 +49,6 @@ function registerArtifactTools(server, client) {
55
49
  shareToken: artifact.shareToken,
56
50
  shareableSlug: slug,
57
51
  duplicate: result?.duplicate === true,
58
- updated: result?.updated === true,
59
52
  title: artifact.title,
60
53
  }),
61
54
  }],
@@ -5,7 +5,7 @@
5
5
 
6
6
  const { z } = require('zod');
7
7
  const FormData = require('form-data');
8
- const { pollUntilDone } = require('../polling');
8
+ const { pollUntilDone, PollingTimeoutError } = require('../polling');
9
9
  const { resolveToBuffer, creditFields } = require('./_shared');
10
10
 
11
11
  function registerGenerateTools(server, client) {
@@ -74,13 +74,16 @@ function registerGenerateTools(server, client) {
74
74
  visual_dna_ids, moodboard_id, enable_web_search, resolution
75
75
  });
76
76
 
77
- // Multi-source compositing or DNA-anchored edits routinely exceed 120s
78
- // server-side. Extend the polling window in those cases to avoid forcing
79
- // every call into the timeout-and-recover path via get_generation_status.
77
+ // Flux/Seedance/DNA-anchored edits routinely run 3-5 min. Old 120s
78
+ // timeout forced every call into the timeout-and-recover path via
79
+ // get_generation_status and when the model fired multiple parallel
80
+ // edits, some recovered, some got abandoned with their URLs lost
81
+ // (the user's "lost generations" bug). 360s/480s covers the realistic
82
+ // p99 single-call duration without making the model poll forever.
80
83
  const heavy = (source_images && source_images.length > 1) || (visual_dna_ids && visual_dna_ids.length > 0);
81
84
  const result = await pollUntilDone(client, gen.generation_id, {
82
85
  interval: (gen.poll_interval_hint || 3) * 1000,
83
- timeout: heavy ? 240000 : 120000
86
+ timeout: heavy ? 480000 : 360000
84
87
  });
85
88
 
86
89
  return {
@@ -109,6 +112,7 @@ function registerGenerateTools(server, client) {
109
112
  aspect_ratio: z.string().optional().describe('Aspect ratio applied to every scene (e.g., "1:1", "16:9", "9:16"). Must be in the chosen model\'s `supported_aspect_ratios` from list_models. Default: "1:1"'),
110
113
  workflow_type: z.string().optional().describe('"image" (default) or "video"'),
111
114
  duration: z.number().optional().describe('Duration in seconds per scene (video mode only). Must be a value in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration`. E.g., 5 or 10.'),
115
+ sound_enabled: z.boolean().optional().describe('Video mode only. Enable (`true`) or disable (`false`) AI-generated synced audio on every scene. Only honored by models with `sound_generation_type: "native"` from list_models (Veo 3.1, Kling V3/2.6/O3, PixVerse V6). Omit to use each model\'s `sound_enabled_by_default`. Pass `false` when the user says no sound / silent / mute / without audio.'),
112
116
  enhance_prompt: z.boolean().optional().describe('Enhance prompts per scene. Default: true'),
113
117
  reference_images: z.array(z.string()).optional().describe('Array of reference image URLs to guide style/composition of every scene. **Cap: pass at most `max_reference_images` URLs from list_models for the chosen model.**'),
114
118
  visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply consistently across every scene. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model.** This is the ideal way to keep a character or product looking the same in all scenes of a campaign.'),
@@ -116,9 +120,9 @@ function registerGenerateTools(server, client) {
116
120
  moodboard_ids: z.array(z.string()).optional().describe('Multiple moodboard IDs when blending styles. Prefer `moodboard_id` for single moodboards.'),
117
121
  resolution: z.string().optional().describe('Resolution tier applied to every scene. Images: "1K" / "2K" / "3K" / "4K". Videos: "720p" / "1080p" / "1440p" / "2160p". Values are model-dependent — call list_models and read supported_resolutions on the target model. Multiplied across every scene.')
118
122
  },
119
- async ({ prompt, scene_count, model, aspect_ratio, workflow_type, duration, enhance_prompt, reference_images, visual_dna_ids, moodboard_id, moodboard_ids, resolution }) => {
123
+ async ({ prompt, scene_count, model, aspect_ratio, workflow_type, duration, sound_enabled, enhance_prompt, reference_images, visual_dna_ids, moodboard_id, moodboard_ids, resolution }) => {
120
124
  const gen = await client.post('/v1/generate/creative-director', {
121
- prompt, scene_count, model, aspect_ratio, workflow_type, duration,
125
+ prompt, scene_count, model, aspect_ratio, workflow_type, duration, sound_enabled,
122
126
  enhance_prompt, reference_images, visual_dna_ids, moodboard_id, moodboard_ids, resolution
123
127
  });
124
128
 
@@ -168,11 +172,12 @@ function registerGenerateTools(server, client) {
168
172
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: true'),
169
173
  reference_images: z.array(z.string()).optional().describe('Array of image URLs used as visual references (style / composition / subject). **Cap: pass at most `max_reference_images` URLs from list_models for the chosen model — exceeding it is a deterministic 400.**'),
170
174
  resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Some models use labels like "512P"/"1024P"/"768P"/"1080P". Model-dependent — call list_models and read supported_resolutions. Read resolution_multipliers to predict cost.'),
175
+ sound_enabled: z.boolean().optional().describe('Enable (`true`) or disable (`false`) AI-generated synced audio on the output video. Only honored by models with `sound_generation_type: "native"` from list_models (e.g. Veo 3.1, Kling V3/2.6, PixVerse V6). On `sound_generation_type: "none"` models the flag has no effect. Omit to use the model\'s `sound_enabled_by_default`. Pass `false` when the user says no sound / silent / mute / without audio. Enabling sound may apply `sound_credit_multiplier` to cost.'),
171
176
  preset_id: z.string().optional().describe('Preset ID from list_presets type="video" to apply a saved motion/style preset to this generation.')
172
177
  },
173
- async ({ prompt, model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, preset_id }) => {
178
+ async ({ prompt, model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, sound_enabled, preset_id }) => {
174
179
  const gen = await client.post('/v1/generate/video', {
175
- prompt, model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, preset_id
180
+ prompt, model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, sound_enabled, preset_id
176
181
  });
177
182
 
178
183
  const result = await pollUntilDone(client, gen.generation_id, {
@@ -209,11 +214,12 @@ function registerGenerateTools(server, client) {
209
214
  duration: z.number().optional().describe('Duration in seconds. Must be in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration`. Default: 5'),
210
215
  enhance_prompt: z.boolean().optional().describe('Enhance the motion prompt. Default: true'),
211
216
  visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to maintain consistency with prior characters / styles. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model; if `supports_visual_dna: false` the model ignores DNA entirely.**'),
212
- resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Some models use labels like "512P"/"1024P"/"768P"/"1080P". Model-dependent — call list_models and read supported_resolutions. Read resolution_multipliers to predict cost.')
217
+ resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Some models use labels like "512P"/"1024P"/"768P"/"1080P". Model-dependent — call list_models and read supported_resolutions. Read resolution_multipliers to predict cost.'),
218
+ sound_enabled: z.boolean().optional().describe('Enable (`true`) or disable (`false`) AI-generated synced audio on the output video. Only honored by models with `sound_generation_type: "native"` from list_models (e.g. Veo 3.1 Lite, Kling V3 4K, PixVerse V6, Kling 2.6/v3). On `sound_generation_type: "none"` models the flag has no effect. Omit to use the model\'s `sound_enabled_by_default`. Pass `false` when the user says no sound / silent / mute / without audio. Enabling sound may apply `sound_credit_multiplier` to cost.')
213
219
  },
214
- async ({ image_url, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution }) => {
220
+ async ({ image_url, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution, sound_enabled }) => {
215
221
  const gen = await client.post('/v1/generate/video/from-image', {
216
- image_url, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution
222
+ image_url, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution, sound_enabled
217
223
  });
218
224
 
219
225
  const result = await pollUntilDone(client, gen.generation_id, {
@@ -383,13 +389,42 @@ function registerGenerateTools(server, client) {
383
389
  // ─── get_generation_status ─────────────────────────────────
384
390
  server.tool(
385
391
  'get_generation_status',
386
- 'Check the status of a generation. Use this as a FALLBACK when a generation tool returned a timeout error — the generation is probably still running on the server. Pass the generation_id from the timeout error (or from any prior generation response).',
392
+ 'Resume polling a generation after a timeout. Pass the generation_id from a prior generation tool that timed out. This call BLOCKS server-side, polling internally — you do NOT need to call it again in a loop. Defaults to a 10-minute internal poll which covers most image edits and short videos; pass `wait_seconds` up to 1700 (~28 min) for long video / 3D / batch generations. **If you fired multiple generations in parallel and they all timed out, you MUST call get_generation_status for EACH generation_id in this same turn — do not give up on any of them, or their URLs will be lost forever.** If a single call returns `still_pending: true`, that specific generation is genuinely slow STOP calling THAT one, tell the user it is still running, and resume on the next user turn. Never call this tool more than ONCE per generation_id consecutively in the same turn.',
387
393
  {
388
- generation_id: z.string().describe('The generation ID to check')
394
+ generation_id: z.string().describe('The generation ID to check'),
395
+ wait_seconds: z.number().int().min(60).max(1700).optional().describe('How long to block-poll internally before giving up (60–1700 seconds, default 600). Values below 300 are clamped up to 300 server-side because shorter waits cause the model to abandon still-running generations. Use higher values for video / 3D / large batches that can legitimately take 15+ minutes.'),
389
396
  },
390
- async ({ generation_id }) => {
391
- const result = await client.get(`/v1/generate/${encodeURIComponent(generation_id)}/status`);
392
-
397
+ async ({ generation_id, wait_seconds }) => {
398
+ // Clamp the floor: shorter waits caused models to abandon generations
399
+ // that completed seconds later (URLs lost forever, paid-for compute
400
+ // wasted). 300s is the floor that survived real-world cases.
401
+ const requested = wait_seconds ?? 600;
402
+ const timeoutMs = Math.max(requested, 300) * 1000;
403
+ let result;
404
+ try {
405
+ result = await pollUntilDone(client, generation_id, {
406
+ interval: 5000,
407
+ timeout: timeoutMs,
408
+ });
409
+ } catch (err) {
410
+ if (err instanceof PollingTimeoutError) {
411
+ const current = await client.get(`/v1/generate/${encodeURIComponent(generation_id)}/status`).catch(() => null);
412
+ const minutes = Math.round(timeoutMs / 60000);
413
+ return {
414
+ content: [{
415
+ type: 'text',
416
+ text: JSON.stringify({
417
+ generation_id,
418
+ state: current?.state ?? 'unknown',
419
+ still_pending: true,
420
+ waited_seconds: Math.round(timeoutMs / 1000),
421
+ _note: `Polled for ${minutes} minute(s) — generation is taking longer than usual. STOP calling get_generation_status now. Tell the user the generation is still running and ask them to prompt you to check again later. Do NOT loop.`,
422
+ }, null, 2),
423
+ }],
424
+ };
425
+ }
426
+ throw err;
427
+ }
393
428
  return {
394
429
  content: [{
395
430
  type: 'text',
@@ -420,9 +455,10 @@ function registerGenerateTools(server, client) {
420
455
  preset_id: z.string().optional().describe('Preset ID from list_presets type="video" (optional)'),
421
456
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: true'),
422
457
  visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply for character/style consistency across outputs. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model.**'),
423
- resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Model-dependent — call list_models and read supported_resolutions.')
458
+ resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Model-dependent — call list_models and read supported_resolutions.'),
459
+ sound_enabled: z.boolean().optional().describe('Enable (`true`) or disable (`false`) AI-generated synced audio on the output. Only honored by models with `sound_generation_type: "native"` from list_models (e.g. Kling O3 4K, Kling O3 via KIE). On other models the flag has no effect. Omit to use the model\'s `sound_enabled_by_default`. Pass `false` when the user says no sound / silent / mute / without audio. Enabling sound may apply `sound_credit_multiplier` to cost.')
424
460
  },
425
- async ({ prompt, model, reference_images, reference_videos, audio_url, files, duration, aspect_ratio, motion, preset_id, enhance_prompt, visual_dna_ids, resolution }) => {
461
+ async ({ prompt, model, reference_images, reference_videos, audio_url, files, duration, aspect_ratio, motion, preset_id, enhance_prompt, visual_dna_ids, resolution, sound_enabled }) => {
426
462
  if (!prompt) throw new Error('prompt is required');
427
463
 
428
464
  let startResponse;
@@ -442,6 +478,7 @@ function registerGenerateTools(server, client) {
442
478
  if (reference_videos) form.append('reference_videos', JSON.stringify(reference_videos));
443
479
  if (audio_url) form.append('audio_url', audio_url);
444
480
  if (resolution) form.append('resolution', resolution);
481
+ if (sound_enabled !== undefined) form.append('sound_enabled', String(sound_enabled));
445
482
  for (const f of resolved) {
446
483
  form.append('files', f.buffer, { filename: f.filename, contentType: f.contentType });
447
484
  }
@@ -449,7 +486,7 @@ function registerGenerateTools(server, client) {
449
486
  } else {
450
487
  // URL-only mode: plain JSON.
451
488
  startResponse = await client.post('/v1/generate/elements', {
452
- prompt, model, reference_images, reference_videos, audio_url, duration, aspect_ratio, motion, preset_id, enhance_prompt, visual_dna_ids, resolution
489
+ prompt, model, reference_images, reference_videos, audio_url, duration, aspect_ratio, motion, preset_id, enhance_prompt, visual_dna_ids, resolution, sound_enabled
453
490
  });
454
491
  }
455
492
 
@@ -488,9 +525,10 @@ function registerGenerateTools(server, client) {
488
525
  aspect_ratio: z.string().optional().describe('Aspect ratio (auto-detected from first frame if not provided). Must be in `supported_aspect_ratios` from list_models when set. Default: "16:9"'),
489
526
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: true'),
490
527
  visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model; if `supports_visual_dna: false`, DNA is silently ignored.**'),
491
- resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Model-dependent — call list_models and read supported_resolutions.')
528
+ resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Model-dependent — call list_models and read supported_resolutions.'),
529
+ sound_enabled: z.boolean().optional().describe('Enable (`true`) or disable (`false`) AI-generated synced audio on the output video. Only honored by models with `sound_generation_type: "native"` from list_models (e.g. Veo 3.1 Lite, Kling V3 4K, PixVerse V6). On other models the flag has no effect. Omit to use the model\'s `sound_enabled_by_default`. Pass `false` when the user says no sound / silent / mute / without audio.')
492
530
  },
493
- async ({ first_frame_url, last_frame_url, first_frame, last_frame, prompt, model, duration, aspect_ratio, enhance_prompt, visual_dna_ids, resolution }) => {
531
+ async ({ first_frame_url, last_frame_url, first_frame, last_frame, prompt, model, duration, aspect_ratio, enhance_prompt, visual_dna_ids, resolution, sound_enabled }) => {
494
532
  const urlMode = first_frame_url && last_frame_url;
495
533
  const fileMode = first_frame && last_frame;
496
534
  if (!urlMode && !fileMode) {
@@ -516,10 +554,11 @@ function registerGenerateTools(server, client) {
516
554
  if (enhance_prompt !== undefined) form.append('enhance_prompt', String(enhance_prompt));
517
555
  if (visual_dna_ids) form.append('visual_dna_ids', JSON.stringify(visual_dna_ids));
518
556
  if (resolution) form.append('resolution', resolution);
557
+ if (sound_enabled !== undefined) form.append('sound_enabled', String(sound_enabled));
519
558
  startResponse = await client.postMultipart('/v1/generate/first-last-frame', form);
520
559
  } else {
521
560
  startResponse = await client.post('/v1/generate/first-last-frame', {
522
- first_frame_url, last_frame_url, prompt, model, duration, aspect_ratio, enhance_prompt, visual_dna_ids, resolution
561
+ first_frame_url, last_frame_url, prompt, model, duration, aspect_ratio, enhance_prompt, visual_dna_ids, resolution, sound_enabled
523
562
  });
524
563
  }
525
564
 
@@ -629,9 +668,10 @@ function registerGenerateTools(server, client) {
629
668
  resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Model-dependent — call list_models and read supported_resolutions.'),
630
669
  reference_images: z.array(z.string()).optional().describe('Array of reference image URLs for models that support additional image inputs. **Cap: pass at most `max_images` URLs from list_models — if `max_images === 0` the model does not accept image refs.** Examples: character reference images for Kling O1/O3, style reference for Aleph/gen4_aleph, character image for WAN VACE video-edit.'),
631
670
  reference_videos: z.array(z.string()).optional().describe('Array of additional reference video URLs for models that support multiple video inputs. **Cap: pass at most `max_videos` URLs from list_models — if `max_videos <= 1` only the source_video is accepted.** Example: WAN 2.6 reference-to-video accepts 1–3 reference videos.'),
632
- elements: z.array(z.string()).optional().describe('Array of element image URLs. **Cap: pass at most `max_elements` URLs from list_models — if `max_elements === 0` the model does not accept elements.** Elements are style or character reference assets alongside the main video.')
671
+ elements: z.array(z.string()).optional().describe('Array of element image URLs. **Cap: pass at most `max_elements` URLs from list_models — if `max_elements === 0` the model does not accept elements.** Elements are style or character reference assets alongside the main video.'),
672
+ sound_enabled: z.boolean().optional().describe('Enable (`true`) or disable (`false`) AI-generated synced audio on the output video. Only honored by models with `sound_generation_type: "native"` from list_models (e.g. Kling v3 via KIE). On other models the flag has no effect. Omit to use the model\'s `sound_enabled_by_default`. Pass `false` when the user says no sound / silent / mute / without audio.')
633
673
  },
634
- async ({ source_video, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution, reference_images, reference_videos, elements }) => {
674
+ async ({ source_video, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution, reference_images, reference_videos, elements, sound_enabled }) => {
635
675
  if (!source_video) throw new Error('source_video is required');
636
676
  if (!prompt) throw new Error('prompt is required');
637
677
 
@@ -640,7 +680,7 @@ function registerGenerateTools(server, client) {
640
680
  if (isUrl) {
641
681
  startResponse = await client.post('/v1/generate/video-from-video', {
642
682
  video_url: source_video, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution,
643
- reference_images, reference_videos, elements
683
+ reference_images, reference_videos, elements, sound_enabled
644
684
  });
645
685
  } else {
646
686
  const resolved = await resolveToBuffer(source_video, 'video');
@@ -656,6 +696,7 @@ function registerGenerateTools(server, client) {
656
696
  if (reference_images) form.append('reference_images', JSON.stringify(reference_images));
657
697
  if (reference_videos) form.append('reference_videos', JSON.stringify(reference_videos));
658
698
  if (elements) form.append('elements', JSON.stringify(elements));
699
+ if (sound_enabled !== undefined) form.append('sound_enabled', String(sound_enabled));
659
700
  startResponse = await client.postMultipart('/v1/generate/video-from-video', form);
660
701
  }
661
702