@kolbo/mcp 1.5.6 → 1.6.8

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
@@ -65,6 +65,8 @@ Just ask Claude naturally:
65
65
 
66
66
  Every image/video/creative-director tool accepts `visual_dna_ids` and `moodboard_id` for character/style consistency across outputs — you can compose `create_visual_dna` → `generate_image` (with the DNA applied server-side) in a single agent turn. `generate_creative_director` also accepts `moodboard_ids` plural for blending.
67
67
 
68
+ Every generation tool also accepts an optional `resolution` arg. Images use `"1K"` (~1024px) / `"2K"` (Full HD) / `"3K"` (QHD) / `"4K"` (UHD); videos use vertical-pixel tiers like `"720p"` / `"1080p"` / `"1440p"` / `"2160p"`. Values are model-dependent — call `list_models` and read the chosen model's `supported_resolutions` and `resolutionMultipliers`. Omit to use the model default.
69
+
68
70
  **Chat & Vision**
69
71
  | Tool | Description |
70
72
  |------|-------------|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.5.6",
3
+ "version": "1.6.8",
4
4
  "description": "Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/client.js CHANGED
@@ -248,6 +248,10 @@ class KolboClient {
248
248
  return this.request('GET', reqPath);
249
249
  }
250
250
 
251
+ async put(reqPath, body = null) {
252
+ return this.request('PUT', reqPath, body);
253
+ }
254
+
251
255
  async delete(reqPath) {
252
256
  return this.request('DELETE', reqPath);
253
257
  }
package/src/index.js CHANGED
@@ -66,6 +66,7 @@ const { registerVisualDnaTools } = require('./tools/visual_dna');
66
66
  const { registerMoodboardTools } = require('./tools/moodboards');
67
67
  const { registerMediaTools } = require('./tools/media');
68
68
  const { registerPresetTools } = require('./tools/presets');
69
+ const { registerAppBuilderTools } = require('./tools/app_builder');
69
70
 
70
71
  async function main() {
71
72
  const client = new KolboClient();
@@ -83,6 +84,7 @@ async function main() {
83
84
  registerMoodboardTools(server, client);
84
85
  registerMediaTools(server, client);
85
86
  registerPresetTools(server, client);
87
+ registerAppBuilderTools(server, client);
86
88
 
87
89
  // Start the server with stdio transport
88
90
  const transport = new StdioServerTransport();
@@ -0,0 +1,253 @@
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
+ const { z } = require('zod');
7
+ const { PollingTimeoutError } = require('../polling');
8
+
9
+ // ─── Build-status polling (App Builder uses a different endpoint than /v1/generate) ──
10
+ async function pollBuildStatus(client, sessionId, options = {}) {
11
+ const {
12
+ interval = 5000,
13
+ timeout = 300000 // 5 minutes
14
+ } = options;
15
+
16
+ const startTime = Date.now();
17
+ const url = `/app-builder/${encodeURIComponent(sessionId)}/build-status`;
18
+
19
+ while (true) {
20
+ if (Date.now() - startTime > timeout) {
21
+ throw new PollingTimeoutError(sessionId, timeout);
22
+ }
23
+
24
+ const result = await client.get(url);
25
+
26
+ if (result.buildStatus === 'deployed') {
27
+ return result;
28
+ }
29
+
30
+ if (result.buildStatus === 'failed') {
31
+ throw new Error(
32
+ `App build failed for session_id="${sessionId}". ` +
33
+ `Call app_builder_get_build_status to check the current state.`
34
+ );
35
+ }
36
+
37
+ await new Promise(resolve => setTimeout(resolve, interval));
38
+ }
39
+ }
40
+
41
+ function registerAppBuilderTools(server, client) {
42
+ // ─── app_builder_list_projects ─────────────────────────────────────────────
43
+ server.tool(
44
+ 'app_builder_list_projects',
45
+ 'List all Kolbo projects for the authenticated user. Use this to find the project_id required by app_builder_create_session and app_builder_list_sessions. Projects are the top-level containers — each project can hold multiple App Builder sessions.',
46
+ {},
47
+ async () => {
48
+ const res = await client.get('/project/lightweight');
49
+ const projects = (Array.isArray(res) ? res : (res.data || [])).map(p => ({
50
+ project_id: p._id,
51
+ name: p.name,
52
+ description: p.description || '',
53
+ created_at: p.createdAt
54
+ }));
55
+ return {
56
+ content: [{ type: 'text', text: JSON.stringify(projects, null, 2) }]
57
+ };
58
+ }
59
+ );
60
+
61
+ // ─── app_builder_create_session ────────────────────────────────────────────
62
+ server.tool(
63
+ 'app_builder_create_session',
64
+ 'Create a new App Builder session inside a Kolbo project. Returns a session_id to pass to app_builder_generate_app. Sessions hold the full app state across multiple generations and edits.',
65
+ {
66
+ project_id: z.string().describe('Kolbo project ID to scope this session. Use app_builder_list_projects to find your project_id.'),
67
+ name: z.string().optional().describe('Optional initial session name. The backend will auto-generate a name on first generation if omitted.')
68
+ },
69
+ async ({ project_id, name }) => {
70
+ const body = name ? { name } : {};
71
+ const res = await client.post(`/app-builder/session/${encodeURIComponent(project_id)}`, body);
72
+ const session = res.data || res;
73
+ return {
74
+ content: [{
75
+ type: 'text',
76
+ text: JSON.stringify({
77
+ session_id: session._id,
78
+ name: session.name,
79
+ build_status: session.buildStatus,
80
+ deployment_url: session.deploymentUrl || null
81
+ }, null, 2)
82
+ }]
83
+ };
84
+ }
85
+ );
86
+
87
+ // ─── app_builder_generate_app ──────────────────────────────────────────────
88
+ server.tool(
89
+ 'app_builder_generate_app',
90
+ 'Generate a React app from a text prompt inside an App Builder session. On the FIRST call the backend auto-generates a punchy app name, URL slug, GitHub repo, and (if needed) a Supabase database. The build runs in the background — this tool polls until the app is deployed (up to 5 minutes) then returns the live deployment_url. Always show the user the deployment_url when done.',
91
+ {
92
+ session_id: z.string().describe('Session ID from app_builder_create_session.'),
93
+ prompt: z.string().describe('Natural language description of the app to build (e.g. "a todo app with drag-and-drop and Supabase persistence").')
94
+ },
95
+ async ({ session_id, prompt }) => {
96
+ await client.post(`/app-builder/generation/${encodeURIComponent(session_id)}`, { userPrompt: prompt });
97
+ const status = await pollBuildStatus(client, session_id);
98
+ return {
99
+ content: [{
100
+ type: 'text',
101
+ text: JSON.stringify({
102
+ session_id,
103
+ build_status: status.buildStatus,
104
+ deployment_url: status.deploymentUrl || null,
105
+ app_name: status.appName || null
106
+ }, null, 2)
107
+ }]
108
+ };
109
+ }
110
+ );
111
+
112
+ // ─── app_builder_edit_app ──────────────────────────────────────────────────
113
+ server.tool(
114
+ 'app_builder_edit_app',
115
+ 'Edit an existing generated app with a natural language instruction — "add a dark mode toggle", "change the color scheme to blue", "add a contact form". Like app_builder_generate_app but for modifications. Use app_builder_list_generations to get the current generation_id before calling this.',
116
+ {
117
+ session_id: z.string().describe('Session ID of the app to edit.'),
118
+ generation_id: z.string().describe('The generation to edit. Use app_builder_list_generations to find the latest generation_id.'),
119
+ edit_prompt: z.string().describe('Natural language instruction describing the change to make.')
120
+ },
121
+ async ({ session_id, generation_id, edit_prompt }) => {
122
+ await client.put(
123
+ `/app-builder/generation/${encodeURIComponent(session_id)}/${encodeURIComponent(generation_id)}`,
124
+ { editPrompt: edit_prompt }
125
+ );
126
+ const status = await pollBuildStatus(client, session_id);
127
+ return {
128
+ content: [{
129
+ type: 'text',
130
+ text: JSON.stringify({
131
+ session_id,
132
+ build_status: status.buildStatus,
133
+ deployment_url: status.deploymentUrl || null,
134
+ app_name: status.appName || null
135
+ }, null, 2)
136
+ }]
137
+ };
138
+ }
139
+ );
140
+
141
+ // ─── app_builder_get_build_status ──────────────────────────────────────────
142
+ server.tool(
143
+ 'app_builder_get_build_status',
144
+ 'Check the current build status of an App Builder session. Use this to manually poll after app_builder_generate_app or app_builder_edit_app, or to check on an app at any time. Returns "deployed" when the live URL is ready.',
145
+ {
146
+ session_id: z.string().describe('Session ID to check.')
147
+ },
148
+ async ({ session_id }) => {
149
+ const result = await client.get(`/app-builder/${encodeURIComponent(session_id)}/build-status`);
150
+ return {
151
+ content: [{
152
+ type: 'text',
153
+ text: JSON.stringify({
154
+ build_status: result.buildStatus,
155
+ deployment_url: result.deploymentUrl || null,
156
+ deployed_at: result.deployedAt || null
157
+ }, null, 2)
158
+ }]
159
+ };
160
+ }
161
+ );
162
+
163
+ // ─── app_builder_get_session ───────────────────────────────────────────────
164
+ server.tool(
165
+ 'app_builder_get_session',
166
+ 'Get full details of an App Builder session including metadata, build status, deployment URL, and GitHub/Supabase integration info. Use this when the user wants to clone the app locally — it returns the GitHub repo URL and Supabase connection details needed for local development.',
167
+ {
168
+ session_id: z.string().describe('Session ID to retrieve.')
169
+ },
170
+ async ({ session_id }) => {
171
+ const res = await client.get(`/app-builder/session/${encodeURIComponent(session_id)}`);
172
+ const session = res.data || res;
173
+ return {
174
+ content: [{
175
+ type: 'text',
176
+ text: JSON.stringify({
177
+ session_id: session._id,
178
+ name: session.name,
179
+ build_status: session.buildStatus,
180
+ deployment_url: session.deploymentUrl || null,
181
+ github_repo_url: session.githubRepoUrl || null,
182
+ supabase_url: session.supabaseUrl || null,
183
+ supabase_anon_key: session.supabaseAnonKey || null,
184
+ created_at: session.createdAt
185
+ }, null, 2)
186
+ }]
187
+ };
188
+ }
189
+ );
190
+
191
+ // ─── app_builder_list_sessions ─────────────────────────────────────────────
192
+ server.tool(
193
+ 'app_builder_list_sessions',
194
+ 'List all App Builder sessions in a project. Use this to find existing sessions before creating a new one, or to pick a session_id to continue working on.',
195
+ {
196
+ project_id: z.string().describe('Kolbo project ID. Use app_builder_list_projects to find it.')
197
+ },
198
+ async ({ project_id }) => {
199
+ const res = await client.get(`/app-builder/sessions/${encodeURIComponent(project_id)}`);
200
+ const sessions = (Array.isArray(res) ? res : (res.data || [])).map(s => ({
201
+ session_id: s._id,
202
+ name: s.name,
203
+ build_status: s.buildStatus,
204
+ deployment_url: s.deploymentUrl || null,
205
+ created_at: s.createdAt
206
+ }));
207
+ return {
208
+ content: [{ type: 'text', text: JSON.stringify(sessions, null, 2) }]
209
+ };
210
+ }
211
+ );
212
+
213
+ // ─── app_builder_list_generations ──────────────────────────────────────────
214
+ server.tool(
215
+ 'app_builder_list_generations',
216
+ 'List all generations for an App Builder session, newest first. Use this to find the current generation_id before calling app_builder_edit_app.',
217
+ {
218
+ session_id: z.string().describe('Session ID to list generations for.')
219
+ },
220
+ async ({ session_id }) => {
221
+ const res = await client.get(`/app-builder/generations/${encodeURIComponent(session_id)}`);
222
+ const generations = (Array.isArray(res) ? res : (res.data || [])).map(g => ({
223
+ generation_id: g._id,
224
+ user_prompt: g.userPrompt || g.editPrompt || '',
225
+ build_status: g.buildStatus,
226
+ created_at: g.createdAt
227
+ }));
228
+ return {
229
+ content: [{ type: 'text', text: JSON.stringify(generations, null, 2) }]
230
+ };
231
+ }
232
+ );
233
+
234
+ // ─── app_builder_delete_session ────────────────────────────────────────────
235
+ server.tool(
236
+ 'app_builder_delete_session',
237
+ 'Permanently delete an App Builder session and ALL associated resources: GitHub repo, Supabase database (unless user-connected), deployed files, generation history, messages, and form submissions. THIS IS IRREVERSIBLE — always confirm with the user before calling.',
238
+ {
239
+ session_id: z.string().describe('Session ID to permanently delete. This cannot be undone.')
240
+ },
241
+ async ({ session_id }) => {
242
+ await client.delete(`/app-builder/session/${encodeURIComponent(session_id)}`);
243
+ return {
244
+ content: [{
245
+ type: 'text',
246
+ text: JSON.stringify({ success: true }, null, 2)
247
+ }]
248
+ };
249
+ }
250
+ );
251
+ }
252
+
253
+ module.exports = { registerAppBuilderTools };
@@ -19,15 +19,17 @@ function registerGenerateTools(server, client) {
19
19
  aspect_ratio: z.string().optional().describe('Aspect ratio (e.g., "1:1", "16:9", "9:16"). Default: "1:1"'),
20
20
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt for better results. Default: true'),
21
21
  num_images: z.number().optional().describe('Number of images to generate in one call. Default: 1'),
22
- reference_images: z.array(z.string()).optional().describe('Array of image URLs used as composition/style references (NOT as source images for editing use generate_image_edit for that).'),
23
- visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs (from create_visual_dna / list_visual_dnas) to apply for character / style / product / scene consistency. Pass the `id` field of each profile. Use this when the user wants to keep the same character or style across multiple images.'),
22
+ reference_images: z.array(z.string()).optional().describe('STYLE/COMPOSITION inspiration only — does NOT embed reference pixels. Array of image URLs used to guide the look-and-feel of a brand-new generation. The model interprets the references and regenerates approximations conditioned on them. It will NOT copy pixels from these images into the output. To embed a specific logo, icon, watermark, or asset pixel-accurately, use generate_image_edit with the asset in source_images. To EDIT an existing image, also use generate_image_edit.'),
23
+ visual_dna_ids: z.array(z.string()).optional().describe('Visual DNA profile IDs (from create_visual_dna / list_visual_dnas) for character / style / product / scene consistency. How DNA works: the server fetches the DNA\'s reference images AND always injects its `description` field into the prompt as plaintext (this is by design — the description carries the identity signal, independent of enhance_prompt). Practical implication: do NOT also write physical descriptors of the same subject in your own prompt — they will compete with the DNA description text. For pixel-accurate face anchoring of a specific person, prefer passing the DNA\'s reference image directly via source_images on generate_image_edit and OMIT visual_dna_ids. visual_dna_ids is best for style / scene / product DNAs and for soft consistency across a set.'),
24
24
  moodboard_id: z.string().optional().describe('Moodboard ID (from list_moodboards / get_moodboard) whose master_prompt and style_guide should be applied to this generation.'),
25
- enable_web_search: z.boolean().optional().describe('Enable web-search grounding for the prompt (useful for current events, brand references, real-world accuracy). Default: false')
25
+ enable_web_search: z.boolean().optional().describe('Enable web-search grounding for the prompt (useful for current events, brand references, real-world accuracy). Default: false'),
26
+ resolution: z.string().optional().describe('Image resolution tier: "1K" (~1024px), "2K" (Full HD), "3K" (QHD), or "4K" (UHD). Model-dependent — call list_models and read supported_resolutions on the chosen model. Read resolution_multipliers on the same model to predict credit cost. Omit to use the model default.'),
27
+ preset_id: z.string().optional().describe('Preset ID from list_presets type="image" to apply a saved style preset to this generation.')
26
28
  },
27
- async ({ prompt, model, aspect_ratio, enhance_prompt, num_images, reference_images, visual_dna_ids, moodboard_id, enable_web_search }) => {
29
+ async ({ prompt, model, aspect_ratio, enhance_prompt, num_images, reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, preset_id }) => {
28
30
  const gen = await client.post('/v1/generate/image', {
29
31
  prompt, model, aspect_ratio, enhance_prompt, num_images,
30
- reference_images, visual_dna_ids, moodboard_id, enable_web_search
32
+ reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, preset_id
31
33
  });
32
34
 
33
35
  const result = await pollUntilDone(client, gen.generation_id, {
@@ -41,7 +43,8 @@ function registerGenerateTools(server, client) {
41
43
  text: JSON.stringify({
42
44
  urls: result.result.urls,
43
45
  model: result.result.model,
44
- prompt_used: result.result.prompt_used
46
+ prompt_used: result.result.prompt_used,
47
+ _followup_hint: 'If the user asks to edit/change/modify this image next, pass urls[0] to generate_image_edit (free-form edits) or edit_image (upscale/reframe/removebg/enhance_skin/magic_edit). Do NOT call generate_image again.'
45
48
  }, null, 2)
46
49
  }]
47
50
  };
@@ -55,23 +58,28 @@ function registerGenerateTools(server, client) {
55
58
  {
56
59
  prompt: z.string().describe('Description of the edit to apply (e.g., "remove the background", "change the sky to sunset")'),
57
60
  model: z.string().optional().describe('Model identifier. Use list_models type="image_editing" to see options. Omit for Smart Select.'),
58
- source_images: z.array(z.string()).describe('Array of source image URLs to edit. Typically one, but some models accept multiple for compositing.'),
61
+ source_images: z.array(z.string()).describe('PIXEL-ACCURATE compositing. Array of source image URLs whose pixel content is composited into the output. Three modes the model auto-detects from input shape: (1) Single image → edit/transform that image. (2) Multiple images, one base + others composite the others into the base. (3) Multiple images with no clear base → generate a new scene that pixel-accurately embeds the supplied images at positions described in the prompt. Mode 3 is the canonical pattern for thumbnails / branded compositions where exact-pixel logo + face fidelity matter. Refer to source images in the prompt by ordinal position ("FIRST source image", "SECOND source image"). Add "composite AS-IS, do not redraw or restyle" to lock pixels.'),
59
62
  aspect_ratio: z.string().optional().describe('Output aspect ratio (e.g., "1:1", "16:9", "9:16"). Default: "1:1"'),
60
63
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt for better results. Default: true'),
61
64
  num_images: z.number().optional().describe('Number of output images. Default: 1'),
62
- visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply for consistency with an existing character / style / product.'),
65
+ visual_dna_ids: z.array(z.string()).optional().describe('Visual DNA profile IDs for character / style / product consistency. How DNA works: the server fetches the DNA\'s reference images AND always injects its `description` field into the prompt as plaintext (by design — independent of enhance_prompt). For pixel-accurate face anchoring of a specific person on this tool, the PREFERRED pattern is to pass the face photo directly via source_images and OMIT visual_dna_ids — that way the face pixels anchor the output and no description text competes. Do NOT pass visual_dna_ids if source_images already contains the same person\'s face (face averaging). visual_dna_ids is best here for style / product DNAs.'),
63
66
  moodboard_id: z.string().optional().describe('Moodboard ID whose master_prompt and style_guide should be applied.'),
64
- enable_web_search: z.boolean().optional().describe('Enable web-search grounding. Default: false')
67
+ enable_web_search: z.boolean().optional().describe('Enable web-search grounding. Default: false'),
68
+ resolution: z.string().optional().describe('Image resolution tier: "1K" / "2K" / "3K" / "4K". Model-dependent — call list_models and read supported_resolutions. Default: "1K" for most edit models.')
65
69
  },
66
- async ({ prompt, model, source_images, aspect_ratio, enhance_prompt, num_images, visual_dna_ids, moodboard_id, enable_web_search }) => {
70
+ async ({ prompt, model, source_images, aspect_ratio, enhance_prompt, num_images, visual_dna_ids, moodboard_id, enable_web_search, resolution }) => {
67
71
  const gen = await client.post('/v1/generate/image-edit', {
68
72
  prompt, model, source_images, aspect_ratio, enhance_prompt, num_images,
69
- visual_dna_ids, moodboard_id, enable_web_search
73
+ visual_dna_ids, moodboard_id, enable_web_search, resolution
70
74
  });
71
75
 
76
+ // Multi-source compositing or DNA-anchored edits routinely exceed 120s
77
+ // server-side. Extend the polling window in those cases to avoid forcing
78
+ // every call into the timeout-and-recover path via get_generation_status.
79
+ const heavy = (source_images && source_images.length > 1) || (visual_dna_ids && visual_dna_ids.length > 0);
72
80
  const result = await pollUntilDone(client, gen.generation_id, {
73
81
  interval: (gen.poll_interval_hint || 3) * 1000,
74
- timeout: 120000
82
+ timeout: heavy ? 240000 : 120000
75
83
  });
76
84
 
77
85
  return {
@@ -80,7 +88,8 @@ function registerGenerateTools(server, client) {
80
88
  text: JSON.stringify({
81
89
  urls: result.result.urls,
82
90
  model: result.result.model,
83
- prompt_used: result.result.prompt_used
91
+ prompt_used: result.result.prompt_used,
92
+ _followup_hint: 'If the user asks for another edit on this output, pass urls[0] back into generate_image_edit as source_images. For targeted ops (upscale/reframe/removebg/enhance_skin) use edit_image instead. Do NOT call generate_image from scratch.'
84
93
  }, null, 2)
85
94
  }]
86
95
  };
@@ -102,12 +111,13 @@ function registerGenerateTools(server, client) {
102
111
  reference_images: z.array(z.string()).optional().describe('Array of reference image URLs to guide style/composition of every scene.'),
103
112
  visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply consistently across every scene. This is the ideal way to keep a character or product looking the same in all scenes of a campaign.'),
104
113
  moodboard_id: z.string().optional().describe('A single moodboard ID whose master_prompt and style_guide should shape every scene.'),
105
- moodboard_ids: z.array(z.string()).optional().describe('Multiple moodboard IDs when blending styles. Prefer `moodboard_id` for single moodboards.')
114
+ moodboard_ids: z.array(z.string()).optional().describe('Multiple moodboard IDs when blending styles. Prefer `moodboard_id` for single moodboards.'),
115
+ 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.')
106
116
  },
107
- async ({ prompt, scene_count, model, aspect_ratio, workflow_type, duration, enhance_prompt, reference_images, visual_dna_ids, moodboard_id, moodboard_ids }) => {
117
+ async ({ prompt, scene_count, model, aspect_ratio, workflow_type, duration, enhance_prompt, reference_images, visual_dna_ids, moodboard_id, moodboard_ids, resolution }) => {
108
118
  const gen = await client.post('/v1/generate/creative-director', {
109
119
  prompt, scene_count, model, aspect_ratio, workflow_type, duration,
110
- enhance_prompt, reference_images, visual_dna_ids, moodboard_id, moodboard_ids
120
+ enhance_prompt, reference_images, visual_dna_ids, moodboard_id, moodboard_ids, resolution
111
121
  });
112
122
 
113
123
  const result = await pollUntilDone(client, gen.generation_id, {
@@ -131,7 +141,8 @@ function registerGenerateTools(server, client) {
131
141
  text: JSON.stringify({
132
142
  scenes,
133
143
  total_scenes: result.scenes?.length || 0,
134
- completed_scenes: scenes.length
144
+ completed_scenes: scenes.length,
145
+ _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.'
135
146
  }, null, 2)
136
147
  }]
137
148
  };
@@ -139,9 +150,13 @@ function registerGenerateTools(server, client) {
139
150
  );
140
151
 
141
152
  // ─── generate_video ────────────────────────────────────────
153
+ // NOTE: text-to-video does NOT support Visual DNA — the textToVideoGeneration
154
+ // controller in kolbo-api never reads visualDnaIds. For character-consistent
155
+ // video, use generate_elements (which DOES honor visual_dna_ids) or animate a
156
+ // DNA-locked still via generate_video_from_image.
142
157
  server.tool(
143
158
  'generate_video',
144
- 'Generate a video from a text prompt using Kolbo AI. For animating an existing still image into motion, use generate_video_from_image instead. For a coordinated multi-scene video campaign, use generate_creative_director with workflow_type="video". Supports Visual DNA profiles (for character consistency) and reference images (for style guidance). Returns the final video URL when complete.',
159
+ 'Generate a video from a text prompt using Kolbo AI. For animating an existing still image into motion, use generate_video_from_image instead. For a coordinated multi-scene video campaign, use generate_creative_director with workflow_type="video". Supports reference images (for style/composition guidance). Does NOT support Visual DNA for character-consistent video use generate_elements or animate a DNA-locked still via generate_video_from_image. Returns the final video URL when complete.',
145
160
  {
146
161
  prompt: z.string().describe('Text description of the video to generate'),
147
162
  model: z.string().optional().describe('Model identifier. Use list_models type="text_to_video" to see options. Check supported_durations and supported_aspect_ratios.'),
@@ -149,11 +164,12 @@ function registerGenerateTools(server, client) {
149
164
  duration: z.number().optional().describe('Duration in seconds. Must be a value the chosen model supports — check supported_durations from list_models. Default: 5'),
150
165
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: true'),
151
166
  reference_images: z.array(z.string()).optional().describe('Array of image URLs used as visual references (style / composition / subject).'),
152
- visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to keep a character / style consistent with prior generations.')
167
+ 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.'),
168
+ preset_id: z.string().optional().describe('Preset ID from list_presets type="video" to apply a saved motion/style preset to this generation.')
153
169
  },
154
- async ({ prompt, model, aspect_ratio, duration, enhance_prompt, reference_images, visual_dna_ids }) => {
170
+ async ({ prompt, model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, preset_id }) => {
155
171
  const gen = await client.post('/v1/generate/video', {
156
- prompt, model, aspect_ratio, duration, enhance_prompt, reference_images, visual_dna_ids
172
+ prompt, model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, preset_id
157
173
  });
158
174
 
159
175
  const result = await pollUntilDone(client, gen.generation_id, {
@@ -169,7 +185,8 @@ function registerGenerateTools(server, client) {
169
185
  model: result.result.model,
170
186
  duration: result.result.duration,
171
187
  thumbnail_url: result.result.thumbnail_url,
172
- prompt_used: result.result.prompt_used
188
+ prompt_used: result.result.prompt_used,
189
+ _followup_hint: 'If the user asks to edit/restyle/extend this video next, pass urls[0] to edit_video (upscale/reframe/face_swap/extend/generate_audio/lipsync/magic_edit) or generate_video_from_video (restyle). Do NOT call generate_video from scratch.'
173
190
  }, null, 2)
174
191
  }]
175
192
  };
@@ -187,11 +204,12 @@ function registerGenerateTools(server, client) {
187
204
  aspect_ratio: z.string().optional().describe('Output aspect ratio (e.g., "16:9", "9:16", "1:1"). Default: "16:9"'),
188
205
  duration: z.number().optional().describe('Duration in seconds. Must be a value the chosen model supports. Default: 5'),
189
206
  enhance_prompt: z.boolean().optional().describe('Enhance the motion prompt. Default: true'),
190
- visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to maintain consistency with prior characters / styles.')
207
+ visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to maintain consistency with prior characters / styles.'),
208
+ 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.')
191
209
  },
192
- async ({ image_url, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids }) => {
210
+ async ({ image_url, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution }) => {
193
211
  const gen = await client.post('/v1/generate/video/from-image', {
194
- image_url, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids
212
+ image_url, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution
195
213
  });
196
214
 
197
215
  const result = await pollUntilDone(client, gen.generation_id, {
@@ -206,7 +224,8 @@ function registerGenerateTools(server, client) {
206
224
  urls: result.result.urls,
207
225
  model: result.result.model,
208
226
  duration: result.result.duration,
209
- thumbnail_url: result.result.thumbnail_url
227
+ thumbnail_url: result.result.thumbnail_url,
228
+ _followup_hint: 'If the user asks to edit/restyle/extend this video next, pass urls[0] to edit_video or generate_video_from_video. Do NOT re-run generate_video_from_image unless they want a fresh animation from a different source image.'
210
229
  }, null, 2)
211
230
  }]
212
231
  };
@@ -224,11 +243,12 @@ function registerGenerateTools(server, client) {
224
243
  instrumental: z.boolean().optional().describe('Generate instrumental only, no vocals. Default: false'),
225
244
  lyrics: z.string().optional().describe('Custom lyrics for the song. If omitted, lyrics are generated automatically from the prompt unless instrumental is true.'),
226
245
  vocal_gender: z.string().optional().describe('Preferred vocal gender: "male" or "female". Only applies when instrumental is false.'),
227
- enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: true')
246
+ enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: true'),
247
+ preset_id: z.string().optional().describe('Preset ID from list_presets type="music" to apply a saved music style preset.')
228
248
  },
229
- async ({ prompt, model, style, instrumental, lyrics, vocal_gender, enhance_prompt }) => {
249
+ async ({ prompt, model, style, instrumental, lyrics, vocal_gender, enhance_prompt, preset_id }) => {
230
250
  const gen = await client.post('/v1/generate/music', {
231
- prompt, model, style, instrumental, lyrics, vocal_gender, enhance_prompt
251
+ prompt, model, style, instrumental, lyrics, vocal_gender, enhance_prompt, preset_id
232
252
  });
233
253
 
234
254
  const result = await pollUntilDone(client, gen.generation_id, {
@@ -290,11 +310,12 @@ function registerGenerateTools(server, client) {
290
310
  {
291
311
  prompt: z.string().describe('Text description of the sound effect (e.g., "thunder clap with rain", "door creaking open", "futuristic UI beep")'),
292
312
  model: z.string().optional().describe('Model identifier. Use list_models type="text_to_sound" to see options. Default: elevenlabs-sound-effects-v1'),
293
- duration: z.number().optional().describe('Duration in seconds. Omit for automatic duration.')
313
+ duration: z.number().optional().describe('Duration in seconds. Omit for automatic duration.'),
314
+ prompt_influence: z.number().optional().describe('How strongly the prompt guides the generation (0–1). Default: 0.5. Lower values give the model more creative freedom; higher values follow the prompt more literally.')
294
315
  },
295
- async ({ prompt, model, duration }) => {
316
+ async ({ prompt, model, duration, prompt_influence }) => {
296
317
  const gen = await client.post('/v1/generate/sound', {
297
- prompt, model, duration
318
+ prompt, model, duration, prompt_influence
298
319
  });
299
320
 
300
321
  const result = await pollUntilDone(client, gen.generation_id, {
@@ -377,20 +398,23 @@ function registerGenerateTools(server, client) {
377
398
  // ─── generate_elements ─────────────────────────────────────
378
399
  server.tool(
379
400
  'generate_elements',
380
- 'Generate a video from reference elements (images and/or videos) + a text prompt. Use when the user wants to animate specific uploaded/referenced assets — e.g. "animate this product", "put these 3 characters into a scene". Supports Visual DNA for character consistency. For text-only → video use generate_video instead. For animating a single still image use generate_video_from_image. Returns the final video URL when complete.',
401
+ 'Generate a video from reference elements (images, videos, and/or audio) + a text prompt. Use when the user wants to animate specific uploaded/referenced assets — e.g. "animate this product", "put these 3 characters into a scene". IMPORTANT: different models accept different numbers of inputs — call list_models type="elements" and read elements_max_images / elements_max_videos / elements_max_audio on the chosen model before generating. For text-only → video use generate_video instead. For animating a single still image use generate_video_from_image. Returns the final video URL when complete.',
381
402
  {
382
403
  prompt: z.string().describe('Text description of the desired video / animation'),
383
- model: z.string().optional().describe('Model identifier. Use list_models type="elements" to see options (Seedance 2, Kling O3 Reference, Grok Imagine, Veo 3.1, etc.). Omit for Smart Select.'),
384
- reference_images: z.array(z.string()).optional().describe('Array of public image URLs used as reference elements (product shots, character references, etc.). URL mode.'),
404
+ model: z.string().optional().describe('Model identifier. Use list_models type="elements" to see options (Seedance 2, Kling O3 Reference, Grok Imagine, Veo 3.1, etc.). Check elements_max_images / elements_max_videos / elements_max_audio on the model. Omit for Smart Select.'),
405
+ reference_images: z.array(z.string()).optional().describe('Array of public image URLs used as reference elements (product shots, character references, etc.). Check elements_max_images on the chosen model — pass at most that many URLs.'),
406
+ reference_videos: z.array(z.string()).optional().describe('Array of reference video URLs for models that accept video inputs (elements_max_videos > 0). Check elements_max_videos on the chosen model from list_models before passing.'),
407
+ audio_url: z.string().optional().describe('URL of a reference audio track for models that accept audio inputs (elements_max_audio > 0). Check elements_max_audio on the chosen model from list_models before passing.'),
385
408
  files: z.array(z.string()).optional().describe('Array of URLs or absolute local paths — alternative to reference_images. Use this when you have local files to upload. Each item can be a URL OR a local path.'),
386
409
  duration: z.number().optional().describe('Duration in seconds. Default: 5'),
387
410
  aspect_ratio: z.string().optional().describe('Aspect ratio (e.g., "16:9", "9:16", "1:1"). Default: "16:9"'),
388
411
  motion: z.string().optional().describe('Motion style / intensity hint (optional)'),
389
412
  preset_id: z.string().optional().describe('Preset ID from list_presets type="video" (optional)'),
390
413
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: true'),
391
- visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply for character/style consistency across outputs.')
414
+ visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply for character/style consistency across outputs.'),
415
+ resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Model-dependent — call list_models and read supported_resolutions.')
392
416
  },
393
- async ({ prompt, model, reference_images, files, duration, aspect_ratio, motion, preset_id, enhance_prompt, visual_dna_ids }) => {
417
+ async ({ prompt, model, reference_images, reference_videos, audio_url, files, duration, aspect_ratio, motion, preset_id, enhance_prompt, visual_dna_ids, resolution }) => {
394
418
  if (!prompt) throw new Error('prompt is required');
395
419
 
396
420
  let startResponse;
@@ -407,6 +431,9 @@ function registerGenerateTools(server, client) {
407
431
  if (enhance_prompt !== undefined) form.append('enhance_prompt', String(enhance_prompt));
408
432
  if (visual_dna_ids) form.append('visual_dna_ids', JSON.stringify(visual_dna_ids));
409
433
  if (reference_images) form.append('reference_images', JSON.stringify(reference_images));
434
+ if (reference_videos) form.append('reference_videos', JSON.stringify(reference_videos));
435
+ if (audio_url) form.append('audio_url', audio_url);
436
+ if (resolution) form.append('resolution', resolution);
410
437
  for (const f of resolved) {
411
438
  form.append('files', f.buffer, { filename: f.filename, contentType: f.contentType });
412
439
  }
@@ -414,7 +441,7 @@ function registerGenerateTools(server, client) {
414
441
  } else {
415
442
  // URL-only mode: plain JSON.
416
443
  startResponse = await client.post('/v1/generate/elements', {
417
- prompt, model, reference_images, duration, aspect_ratio, motion, preset_id, enhance_prompt, visual_dna_ids
444
+ prompt, model, reference_images, reference_videos, audio_url, duration, aspect_ratio, motion, preset_id, enhance_prompt, visual_dna_ids, resolution
418
445
  });
419
446
  }
420
447
 
@@ -451,9 +478,10 @@ function registerGenerateTools(server, client) {
451
478
  duration: z.number().optional().describe('Duration in seconds. Default: 5'),
452
479
  aspect_ratio: z.string().optional().describe('Aspect ratio (auto-detected from first frame if not provided). Default: "16:9"'),
453
480
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: true'),
454
- visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply.')
481
+ visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply.'),
482
+ resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Model-dependent — call list_models and read supported_resolutions.')
455
483
  },
456
- async ({ first_frame_url, last_frame_url, first_frame, last_frame, prompt, model, duration, aspect_ratio, enhance_prompt, visual_dna_ids }) => {
484
+ async ({ first_frame_url, last_frame_url, first_frame, last_frame, prompt, model, duration, aspect_ratio, enhance_prompt, visual_dna_ids, resolution }) => {
457
485
  const urlMode = first_frame_url && last_frame_url;
458
486
  const fileMode = first_frame && last_frame;
459
487
  if (!urlMode && !fileMode) {
@@ -478,10 +506,11 @@ function registerGenerateTools(server, client) {
478
506
  if (aspect_ratio) form.append('aspect_ratio', aspect_ratio);
479
507
  if (enhance_prompt !== undefined) form.append('enhance_prompt', String(enhance_prompt));
480
508
  if (visual_dna_ids) form.append('visual_dna_ids', JSON.stringify(visual_dna_ids));
509
+ if (resolution) form.append('resolution', resolution);
481
510
  startResponse = await client.postMultipart('/v1/generate/first-last-frame', form);
482
511
  } else {
483
512
  startResponse = await client.post('/v1/generate/first-last-frame', {
484
- first_frame_url, last_frame_url, prompt, model, duration, aspect_ratio, enhance_prompt, visual_dna_ids
513
+ first_frame_url, last_frame_url, prompt, model, duration, aspect_ratio, enhance_prompt, visual_dna_ids, resolution
485
514
  });
486
515
  }
487
516
 
@@ -577,17 +606,21 @@ function registerGenerateTools(server, client) {
577
606
  // ─── generate_video_from_video ─────────────────────────────
578
607
  server.tool(
579
608
  'generate_video_from_video',
580
- 'Restyle / transform an existing video using a text prompt (video-to-video). Use for style transfer, scene restyling, subject swap — anything where you want to keep the motion from the input video but change the look. Source video can be a URL or absolute local path. For animating a still image use generate_video_from_image instead. For text-only → video use generate_video.',
609
+ 'Restyle / transform an existing video using a text prompt (video-to-video). Use for style transfer, scene restyling, subject swap, motion transfer, or character replacement. Source video can be a URL or absolute local path. IMPORTANT: different models support different extra inputs call list_models type="video_to_video" and read max_images / max_videos / max_elements on the chosen model before generating. Pass reference_images for models with max_images > 0 (e.g. Kling O1/O3, Aleph, WAN VACE), reference_videos for models with max_videos > 1 (e.g. WAN 2.6 reference-to-video accepts up to 3), and elements for models with max_elements > 0. For animating a still image use generate_video_from_image instead. For text-only → video use generate_video.',
581
610
  {
582
- source_video: z.string().describe('URL or absolute local path to the source video to restyle'),
611
+ source_video: z.string().describe('URL or absolute local path to the primary source video to restyle. For models that use reference_videos as their primary input (e.g. WAN 2.6 reference-to-video), pass the first reference video here and also include it in reference_videos.'),
583
612
  prompt: z.string().describe('Text description of the desired restyle / transformation'),
584
- model: z.string().optional().describe('Model identifier. Use list_models type="video_to_video" to see options. Omit for Smart Select.'),
613
+ model: z.string().optional().describe('Model identifier. Use list_models type="video_to_video" to see options and check max_images / max_videos / max_elements per model. Omit for Smart Select.'),
585
614
  aspect_ratio: z.string().optional().describe('Output aspect ratio. Default: matches source'),
586
615
  duration: z.number().optional().describe('Duration in seconds (default: matches source)'),
587
616
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: true'),
588
- visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply for character/style consistency.')
617
+ visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply for character/style consistency.'),
618
+ resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Model-dependent — call list_models and read supported_resolutions.'),
619
+ reference_images: z.array(z.string()).optional().describe('Array of reference image URLs for models that support additional image inputs (max_images > 0). Examples: character reference images for Kling O1/O3, style reference for Aleph/gen4_aleph, character image for WAN VACE video-edit. Check max_images on the model from list_models before passing.'),
620
+ reference_videos: z.array(z.string()).optional().describe('Array of additional reference video URLs for models that support multiple video inputs (max_videos > 1). Example: WAN 2.6 reference-to-video accepts 1–3 reference videos. Check max_videos on the model from list_models before passing.'),
621
+ elements: z.array(z.string()).optional().describe('Array of element image URLs for models with max_elements > 0. Elements are used as style or character reference assets alongside the main video. Check max_elements on the model from list_models before passing.')
589
622
  },
590
- async ({ source_video, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids }) => {
623
+ async ({ source_video, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution, reference_images, reference_videos, elements }) => {
591
624
  if (!source_video) throw new Error('source_video is required');
592
625
  if (!prompt) throw new Error('prompt is required');
593
626
 
@@ -595,7 +628,8 @@ function registerGenerateTools(server, client) {
595
628
  let startResponse;
596
629
  if (isUrl) {
597
630
  startResponse = await client.post('/v1/generate/video-from-video', {
598
- video_url: source_video, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids
631
+ video_url: source_video, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution,
632
+ reference_images, reference_videos, elements
599
633
  });
600
634
  } else {
601
635
  const resolved = await resolveToBuffer(source_video, 'video');
@@ -607,6 +641,10 @@ function registerGenerateTools(server, client) {
607
641
  if (duration !== undefined) form.append('duration', String(duration));
608
642
  if (enhance_prompt !== undefined) form.append('enhance_prompt', String(enhance_prompt));
609
643
  if (visual_dna_ids) form.append('visual_dna_ids', JSON.stringify(visual_dna_ids));
644
+ if (resolution) form.append('resolution', resolution);
645
+ if (reference_images) form.append('reference_images', JSON.stringify(reference_images));
646
+ if (reference_videos) form.append('reference_videos', JSON.stringify(reference_videos));
647
+ if (elements) form.append('elements', JSON.stringify(elements));
610
648
  startResponse = await client.postMultipart('/v1/generate/video-from-video', form);
611
649
  }
612
650
 
@@ -720,6 +758,96 @@ function registerGenerateTools(server, client) {
720
758
  };
721
759
  }
722
760
  );
761
+ // ─── edit_image ────────────────────────────────────────────
762
+ server.tool(
763
+ 'edit_image',
764
+ 'Apply a targeted AI edit to an existing image. Use for upscaling resolution, changing aspect ratio (reframe), removing the background, portrait skin enhancement, or a text-guided edit (magic_edit). Faster and cheaper than generate_image_edit for these specific operations because it routes to specialized models. Returns the edited image URL when complete.',
765
+ {
766
+ image_url: z.string().describe('URL of the source image to edit'),
767
+ operation: z.enum(['upscale', 'reframe', 'removebg', 'enhance_skin', 'magic_edit'])
768
+ .describe('Edit operation to apply: "upscale" (increase resolution 2×–4×), "reframe" (change aspect ratio), "removebg" (remove background), "enhance_skin" (portrait retouching), "magic_edit" (text-guided edit — requires prompt)'),
769
+ model: z.string().optional().describe('Model identifier override. Omit to use the default model for the operation.'),
770
+ scale: z.number().optional().describe('Upscale factor: 2, 3, or 4. Only used when operation="upscale". Default: 2.'),
771
+ aspect_ratio: z.string().optional().describe('Target aspect ratio (e.g., "16:9", "9:16", "1:1"). Required for operation="reframe".'),
772
+ skin_strength: z.enum(['subtle', 'realistic', 'pimple', 'freckle']).optional()
773
+ .describe('Skin enhancement style. Only used when operation="enhance_skin". Default: "realistic".'),
774
+ prompt: z.string().optional().describe('Text instruction for the edit. Required for operation="magic_edit" (e.g., "add sunglasses", "change the sky to sunset").')
775
+ },
776
+ async ({ image_url, operation, model, scale, aspect_ratio, skin_strength, prompt }) => {
777
+ if (operation === 'magic_edit' && !prompt) throw new Error('prompt is required for magic_edit operation');
778
+ if (operation === 'reframe' && !aspect_ratio) throw new Error('aspect_ratio is required for reframe operation');
779
+
780
+ const gen = await client.post('/v1/edit/image', {
781
+ image_url, operation, model, scale, aspect_ratio, skin_strength, prompt
782
+ });
783
+
784
+ const result = await pollUntilDone(client, gen.generation_id, {
785
+ interval: (gen.poll_interval_hint || 5) * 1000,
786
+ timeout: 180000
787
+ });
788
+
789
+ return {
790
+ content: [{
791
+ type: 'text',
792
+ text: JSON.stringify({
793
+ urls: result.result?.urls || [],
794
+ edit_type: result.result?.edit_type || null,
795
+ model: result.result?.model || null
796
+ }, null, 2)
797
+ }]
798
+ };
799
+ }
800
+ );
801
+
802
+ // ─── edit_video ────────────────────────────────────────────
803
+ server.tool(
804
+ 'edit_video',
805
+ 'Apply a targeted AI edit to an existing video. Operations: upscale (4K resolution boost), reframe (change aspect ratio), generate_audio (add AI-generated sound/music from a prompt), remove_watermark, face_swap (replace faces using a reference image URL), extend (lengthen at start or end), magic_edit (restyle/transform with a prompt), lipsync (sync an audio track to a face in the video). Returns the edited video URL when complete.',
806
+ {
807
+ video_url: z.string().describe('URL of the source video to edit'),
808
+ operation: z.enum(['upscale', 'reframe', 'generate_audio', 'remove_watermark', 'face_swap', 'extend', 'magic_edit', 'lipsync'])
809
+ .describe('Edit operation: "upscale", "reframe" (requires aspect_ratio), "generate_audio" (requires prompt), "remove_watermark", "face_swap" (requires image_url), "extend" (requires duration), "magic_edit" (requires prompt), "lipsync" (requires audio_url)'),
810
+ model: z.string().optional().describe('Model identifier override. Omit to use the default model for the operation.'),
811
+ aspect_ratio: z.string().optional().describe('Target aspect ratio (e.g., "16:9", "9:16"). Required for operation="reframe".'),
812
+ scale: z.number().optional().describe('Upscale factor. Only used when operation="upscale".'),
813
+ prompt: z.string().optional().describe('Text prompt. Required for operation="magic_edit" and "generate_audio". Optional hint for "extend".'),
814
+ image_url: z.string().optional().describe('URL of the reference face image. Required for operation="face_swap".'),
815
+ audio_url: z.string().optional().describe('URL of the audio track to sync. Required for operation="lipsync".'),
816
+ duration: z.number().optional().describe('Seconds of video to generate. Required for operation="extend". Typical range: 1–20.'),
817
+ mode: z.string().optional().describe('Where to extend: "start" or "end". Only used when operation="extend". Default: "end".')
818
+ },
819
+ async ({ video_url, operation, model, aspect_ratio, scale, prompt, image_url, audio_url, duration, mode }) => {
820
+ if (operation === 'magic_edit' && !prompt) throw new Error('prompt is required for magic_edit');
821
+ if (operation === 'generate_audio' && !prompt) throw new Error('prompt is required for generate_audio');
822
+ if (operation === 'reframe' && !aspect_ratio) throw new Error('aspect_ratio is required for reframe');
823
+ if (operation === 'face_swap' && !image_url) throw new Error('image_url (reference face) is required for face_swap');
824
+ if (operation === 'lipsync' && !audio_url) throw new Error('audio_url is required for lipsync');
825
+ if (operation === 'extend' && !duration) throw new Error('duration is required for extend');
826
+
827
+ const gen = await client.post('/v1/edit/video', {
828
+ video_url, operation, model, aspect_ratio, scale, prompt,
829
+ image_url, audio_url, duration, mode
830
+ });
831
+
832
+ const result = await pollUntilDone(client, gen.generation_id, {
833
+ interval: (gen.poll_interval_hint || 8) * 1000,
834
+ timeout: 600000
835
+ });
836
+
837
+ return {
838
+ content: [{
839
+ type: 'text',
840
+ text: JSON.stringify({
841
+ urls: result.result?.urls || [],
842
+ download_url: result.result?.download_url || null,
843
+ edit_type: result.result?.edit_type || null,
844
+ duration: result.result?.duration || null,
845
+ model: result.result?.model || null
846
+ }, null, 2)
847
+ }]
848
+ };
849
+ }
850
+ );
723
851
  }
724
852
 
725
853
  module.exports = { registerGenerateTools };
@@ -21,8 +21,64 @@ function registerModelTools(server, client) {
21
21
  const withSummary = result.models.filter(m => m.summary && m.summary.trim() !== '');
22
22
  const withoutSummary = result.models.filter(m => !m.summary || m.summary.trim() === '');
23
23
 
24
+ // Format the per-model spec line. The agent NEEDS this — without it,
25
+ // it has to guess `supported_resolutions`/`supported_durations` and
26
+ // either invents values (then the API silently substitutes) or asks
27
+ // the user to clarify what's only knowable from this list.
28
+ const formatSpecs = m => {
29
+ const parts = [];
30
+
31
+ if (Array.isArray(m.supported_resolutions) && m.supported_resolutions.length) {
32
+ const mult = m.resolution_multipliers || {};
33
+ parts.push(
34
+ 'resolutions: ' +
35
+ m.supported_resolutions
36
+ .map(r => (mult[r] != null && mult[r] !== 1 ? `${r} (${mult[r]}×)` : r))
37
+ .join(' · ')
38
+ );
39
+ }
40
+
41
+ if (Array.isArray(m.supported_durations) && m.supported_durations.length) {
42
+ const ds = m.supported_durations;
43
+ // Compact ranges like 4-15 if it's a contiguous run.
44
+ const sorted = [...ds].sort((a, b) => a - b);
45
+ const isRange = sorted.length > 2 && sorted.every((v, i) => i === 0 || v - sorted[i - 1] === 1);
46
+ parts.push(`durations: ${isRange ? `${sorted[0]}-${sorted[sorted.length - 1]}s` : sorted.join('/') + 's'}`);
47
+ }
48
+
49
+ if (Array.isArray(m.supported_aspect_ratios) && m.supported_aspect_ratios.length) {
50
+ parts.push(`aspect: ${m.supported_aspect_ratios.join(', ')}`);
51
+ }
52
+
53
+ // Elements-type caps (only show when at least one is non-zero)
54
+ const eImg = m.elements_max_images, eVid = m.elements_max_videos, eAud = m.elements_max_audio;
55
+ if ((eImg ?? 0) > 0 || (eVid ?? 0) > 0 || (eAud ?? 0) > 0) {
56
+ parts.push(`elements: ${eImg ?? 0} imgs / ${eVid ?? 0} vids / ${eAud ?? 0} audio`);
57
+ }
58
+
59
+ // Video-to-video / multi-input caps
60
+ const mImg = m.max_images, mVid = m.max_videos, mElm = m.max_elements;
61
+ if ((mImg ?? 0) > 0 || (mVid ?? 0) > 0 || (mElm ?? 0) > 0) {
62
+ parts.push(`refs: ${mImg ?? 0} imgs / ${mVid ?? 0} vids / ${mElm ?? 0} elms`);
63
+ }
64
+
65
+ if ((m.max_visual_dna ?? 0) > 0) parts.push(`max_dna: ${m.max_visual_dna}`);
66
+
67
+ // Sound (only show when sound costs more or is generated natively)
68
+ if (m.sound_generation_type === 'native') {
69
+ const mult = m.sound_credit_multiplier && m.sound_credit_multiplier !== 1
70
+ ? ` (${m.sound_credit_multiplier}×)`
71
+ : '';
72
+ parts.push(`sound: native${mult}${m.sound_enabled_by_default ? ' on-by-default' : ''}`);
73
+ }
74
+
75
+ if (m.max_audio_duration != null) parts.push(`audio_max: ${m.max_audio_duration}s`);
76
+
77
+ return parts.length ? `\n ${parts.join(' | ')}` : '';
78
+ };
79
+
24
80
  const formatModel = m =>
25
- `${m.identifier} (${m.name}) - ${m.credit} credits${m.recommended ? ' [RECOMMENDED]' : ''}${m.new_model ? ' [NEW]' : ''}${m.summary ? ` — ${m.summary}` : ''}`;
81
+ `${m.identifier} (${m.name}) - ${m.credit} credits${m.recommended ? ' [RECOMMENDED]' : ''}${m.new_model ? ' [NEW]' : ''}${m.summary ? ` — ${m.summary}` : ''}${formatSpecs(m)}`;
26
82
 
27
83
  const sections = [];
28
84
  if (withSummary.length > 0) {