@kolbo/mcp 1.6.5 → 1.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.6.5",
3
+ "version": "1.6.9",
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
@@ -198,12 +198,21 @@ class KolboClient {
198
198
 
199
199
  async _doRequest(method, reqPath, body = null) {
200
200
  const url = `${this.baseUrl}${reqPath}`;
201
+ const headers = {
202
+ 'X-API-Key': this.apiKey,
203
+ 'Content-Type': 'application/json'
204
+ };
205
+ // Stable per-app-launch identifier from the parent process (Kolbo Code
206
+ // sets this in the MCP env when spawning us). kolbo-api tags every
207
+ // CreditUsage record with it so the desktop UI and the get_session_usage
208
+ // tool can aggregate spend without enumerating individual generations.
209
+ const callerSessionId = process.env.KOLBO_CALLER_SESSION_ID;
210
+ if (callerSessionId) {
211
+ headers['X-Kolbo-Caller-Session-Id'] = callerSessionId;
212
+ }
201
213
  const options = {
202
214
  method,
203
- headers: {
204
- 'X-API-Key': this.apiKey,
205
- 'Content-Type': 'application/json'
206
- }
215
+ headers,
207
216
  };
208
217
 
209
218
  if (body) {
@@ -248,6 +257,10 @@ class KolboClient {
248
257
  return this.request('GET', reqPath);
249
258
  }
250
259
 
260
+ async put(reqPath, body = null) {
261
+ return this.request('PUT', reqPath, body);
262
+ }
263
+
251
264
  async delete(reqPath) {
252
265
  return this.request('DELETE', reqPath);
253
266
  }
@@ -266,6 +279,11 @@ class KolboClient {
266
279
  'X-API-Key': this.apiKey,
267
280
  ...formData.getHeaders()
268
281
  };
282
+ // Same caller-session header as JSON requests — see _doRequest.
283
+ const callerSessionId = process.env.KOLBO_CALLER_SESSION_ID;
284
+ if (callerSessionId) {
285
+ headers['X-Kolbo-Caller-Session-Id'] = callerSessionId;
286
+ }
269
287
 
270
288
  // Serialize form-data to a Buffer before passing to fetch(). Node's
271
289
  // built-in fetch (undici) can't consume legacy Node.js streams from
@@ -200,6 +200,36 @@ async function resolveToBuffer(source, kind, opts = {}) {
200
200
  };
201
201
  }
202
202
 
203
+ /**
204
+ * Extract real, multiplier-adjusted credit cost from a polled getStatus
205
+ * response. kolbo-api returns `credits_used` (final number deducted) and
206
+ * `credits_breakdown` (per-CreditUsage detail) when the generation is
207
+ * complete. Returns `{}` when the API didn't include them so spreading
208
+ * the result into a tool's response object is a no-op (forward-compatible
209
+ * with old kolbo-api versions).
210
+ *
211
+ * Usage in every generation tool:
212
+ * return {
213
+ * content: [{ type: 'text', text: JSON.stringify({
214
+ * urls: result.result.urls,
215
+ * model: result.result.model,
216
+ * ...creditFields(result), // adds credits_used + credits_breakdown
217
+ * _followup_hint: '...',
218
+ * }, null, 2) }]
219
+ * };
220
+ */
221
+ function creditFields(polledResult) {
222
+ if (!polledResult) return {};
223
+ const out = {};
224
+ if (typeof polledResult.credits_used === 'number') {
225
+ out.credits_used = polledResult.credits_used;
226
+ }
227
+ if (Array.isArray(polledResult.credits_breakdown) && polledResult.credits_breakdown.length) {
228
+ out.credits_breakdown = polledResult.credits_breakdown;
229
+ }
230
+ return out;
231
+ }
232
+
203
233
  module.exports = {
204
234
  MAX_FILE_BYTES,
205
235
  VISUAL_DNA_MAX_BYTES,
@@ -208,5 +238,6 @@ module.exports = {
208
238
  safeFetch,
209
239
  guessFilename,
210
240
  guessContentType,
211
- resolveToBuffer
241
+ resolveToBuffer,
242
+ creditFields,
212
243
  };
@@ -119,8 +119,7 @@ function registerAppBuilderTools(server, client) {
119
119
  edit_prompt: z.string().describe('Natural language instruction describing the change to make.')
120
120
  },
121
121
  async ({ session_id, generation_id, edit_prompt }) => {
122
- await client.request(
123
- 'PUT',
122
+ await client.put(
124
123
  `/app-builder/generation/${encodeURIComponent(session_id)}/${encodeURIComponent(generation_id)}`,
125
124
  { editPrompt: edit_prompt }
126
125
  );
package/src/tools/chat.js CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  const { z } = require('zod');
7
7
  const { pollUntilDone } = require('../polling');
8
+ const { creditFields } = require('./_shared');
8
9
 
9
10
  function registerChatTools(server, client) {
10
11
  // ─── chat_send_message ─────────────────────────────────────
@@ -49,6 +50,7 @@ function registerChatTools(server, client) {
49
50
  content: [{
50
51
  type: 'text',
51
52
  text: JSON.stringify({
53
+ ...creditFields(result),
52
54
  session_id: gen.session_id,
53
55
  message_id: gen.message_id,
54
56
  model: r.model || gen.model,
@@ -6,7 +6,7 @@
6
6
  const { z } = require('zod');
7
7
  const FormData = require('form-data');
8
8
  const { pollUntilDone } = require('../polling');
9
- const { resolveToBuffer } = require('./_shared');
9
+ const { resolveToBuffer, creditFields } = require('./_shared');
10
10
 
11
11
  function registerGenerateTools(server, client) {
12
12
  // ─── generate_image ────────────────────────────────────────
@@ -19,11 +19,11 @@ 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
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 resolutionMultipliers on the same model to predict credit cost. Omit to use the model default.'),
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
27
  preset_id: z.string().optional().describe('Preset ID from list_presets type="image" to apply a saved style preset to this generation.')
28
28
  },
29
29
  async ({ prompt, model, aspect_ratio, enhance_prompt, num_images, reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, preset_id }) => {
@@ -41,9 +41,11 @@ function registerGenerateTools(server, client) {
41
41
  content: [{
42
42
  type: 'text',
43
43
  text: JSON.stringify({
44
+ ...creditFields(result),
44
45
  urls: result.result.urls,
45
46
  model: result.result.model,
46
- prompt_used: result.result.prompt_used
47
+ prompt_used: result.result.prompt_used,
48
+ _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.'
47
49
  }, null, 2)
48
50
  }]
49
51
  };
@@ -57,11 +59,11 @@ function registerGenerateTools(server, client) {
57
59
  {
58
60
  prompt: z.string().describe('Description of the edit to apply (e.g., "remove the background", "change the sky to sunset")'),
59
61
  model: z.string().optional().describe('Model identifier. Use list_models type="image_editing" to see options. Omit for Smart Select.'),
60
- source_images: z.array(z.string()).describe('Array of source image URLs to edit. Typically one, but some models accept multiple for compositing.'),
62
+ 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.'),
61
63
  aspect_ratio: z.string().optional().describe('Output aspect ratio (e.g., "1:1", "16:9", "9:16"). Default: "1:1"'),
62
64
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt for better results. Default: true'),
63
65
  num_images: z.number().optional().describe('Number of output images. Default: 1'),
64
- 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.'),
66
+ 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.'),
65
67
  moodboard_id: z.string().optional().describe('Moodboard ID whose master_prompt and style_guide should be applied.'),
66
68
  enable_web_search: z.boolean().optional().describe('Enable web-search grounding. Default: false'),
67
69
  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.')
@@ -72,18 +74,24 @@ function registerGenerateTools(server, client) {
72
74
  visual_dna_ids, moodboard_id, enable_web_search, resolution
73
75
  });
74
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.
80
+ const heavy = (source_images && source_images.length > 1) || (visual_dna_ids && visual_dna_ids.length > 0);
75
81
  const result = await pollUntilDone(client, gen.generation_id, {
76
82
  interval: (gen.poll_interval_hint || 3) * 1000,
77
- timeout: 120000
83
+ timeout: heavy ? 240000 : 120000
78
84
  });
79
85
 
80
86
  return {
81
87
  content: [{
82
88
  type: 'text',
83
89
  text: JSON.stringify({
90
+ ...creditFields(result),
84
91
  urls: result.result.urls,
85
92
  model: result.result.model,
86
- prompt_used: result.result.prompt_used
93
+ prompt_used: result.result.prompt_used,
94
+ _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.'
87
95
  }, null, 2)
88
96
  }]
89
97
  };
@@ -133,9 +141,11 @@ function registerGenerateTools(server, client) {
133
141
  content: [{
134
142
  type: 'text',
135
143
  text: JSON.stringify({
144
+ ...creditFields(result),
136
145
  scenes,
137
146
  total_scenes: result.scenes?.length || 0,
138
- completed_scenes: scenes.length
147
+ completed_scenes: scenes.length,
148
+ _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.'
139
149
  }, null, 2)
140
150
  }]
141
151
  };
@@ -143,9 +153,13 @@ function registerGenerateTools(server, client) {
143
153
  );
144
154
 
145
155
  // ─── generate_video ────────────────────────────────────────
156
+ // NOTE: text-to-video does NOT support Visual DNA — the textToVideoGeneration
157
+ // controller in kolbo-api never reads visualDnaIds. For character-consistent
158
+ // video, use generate_elements (which DOES honor visual_dna_ids) or animate a
159
+ // DNA-locked still via generate_video_from_image.
146
160
  server.tool(
147
161
  'generate_video',
148
- '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.',
162
+ '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.',
149
163
  {
150
164
  prompt: z.string().describe('Text description of the video to generate'),
151
165
  model: z.string().optional().describe('Model identifier. Use list_models type="text_to_video" to see options. Check supported_durations and supported_aspect_ratios.'),
@@ -153,13 +167,12 @@ function registerGenerateTools(server, client) {
153
167
  duration: z.number().optional().describe('Duration in seconds. Must be a value the chosen model supports — check supported_durations from list_models. Default: 5'),
154
168
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: true'),
155
169
  reference_images: z.array(z.string()).optional().describe('Array of image URLs used as visual references (style / composition / subject).'),
156
- visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to keep a character / style consistent with prior generations.'),
157
- 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 resolutionMultipliers to predict cost.'),
170
+ 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.'),
158
171
  preset_id: z.string().optional().describe('Preset ID from list_presets type="video" to apply a saved motion/style preset to this generation.')
159
172
  },
160
- async ({ prompt, model, aspect_ratio, duration, enhance_prompt, reference_images, visual_dna_ids, resolution, preset_id }) => {
173
+ async ({ prompt, model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, preset_id }) => {
161
174
  const gen = await client.post('/v1/generate/video', {
162
- prompt, model, aspect_ratio, duration, enhance_prompt, reference_images, visual_dna_ids, resolution, preset_id
175
+ prompt, model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, preset_id
163
176
  });
164
177
 
165
178
  const result = await pollUntilDone(client, gen.generation_id, {
@@ -171,11 +184,13 @@ function registerGenerateTools(server, client) {
171
184
  content: [{
172
185
  type: 'text',
173
186
  text: JSON.stringify({
187
+ ...creditFields(result),
174
188
  urls: result.result.urls,
175
189
  model: result.result.model,
176
190
  duration: result.result.duration,
177
191
  thumbnail_url: result.result.thumbnail_url,
178
- prompt_used: result.result.prompt_used
192
+ prompt_used: result.result.prompt_used,
193
+ _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.'
179
194
  }, null, 2)
180
195
  }]
181
196
  };
@@ -194,7 +209,7 @@ function registerGenerateTools(server, client) {
194
209
  duration: z.number().optional().describe('Duration in seconds. Must be a value the chosen model supports. Default: 5'),
195
210
  enhance_prompt: z.boolean().optional().describe('Enhance the motion prompt. Default: true'),
196
211
  visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to maintain consistency with prior characters / styles.'),
197
- 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 resolutionMultipliers to predict cost.')
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.')
198
213
  },
199
214
  async ({ image_url, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution }) => {
200
215
  const gen = await client.post('/v1/generate/video/from-image', {
@@ -210,10 +225,12 @@ function registerGenerateTools(server, client) {
210
225
  content: [{
211
226
  type: 'text',
212
227
  text: JSON.stringify({
228
+ ...creditFields(result),
213
229
  urls: result.result.urls,
214
230
  model: result.result.model,
215
231
  duration: result.result.duration,
216
- thumbnail_url: result.result.thumbnail_url
232
+ thumbnail_url: result.result.thumbnail_url,
233
+ _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.'
217
234
  }, null, 2)
218
235
  }]
219
236
  };
@@ -248,6 +265,7 @@ function registerGenerateTools(server, client) {
248
265
  content: [{
249
266
  type: 'text',
250
267
  text: JSON.stringify({
268
+ ...creditFields(result),
251
269
  urls: result.result.urls,
252
270
  title: result.result.title,
253
271
  duration: result.result.duration,
@@ -282,6 +300,7 @@ function registerGenerateTools(server, client) {
282
300
  content: [{
283
301
  type: 'text',
284
302
  text: JSON.stringify({
303
+ ...creditFields(result),
285
304
  urls: result.result.urls,
286
305
  voice: result.result.voice,
287
306
  duration: result.result.duration
@@ -315,6 +334,7 @@ function registerGenerateTools(server, client) {
315
334
  content: [{
316
335
  type: 'text',
317
336
  text: JSON.stringify({
337
+ ...creditFields(result),
318
338
  urls: result.result.urls,
319
339
  duration: result.result.duration
320
340
  }, null, 2)
@@ -386,13 +406,13 @@ function registerGenerateTools(server, client) {
386
406
  // ─── generate_elements ─────────────────────────────────────
387
407
  server.tool(
388
408
  'generate_elements',
389
- '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 elementsMaxImages / elementsMaxVideos / elementsMaxAudio 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.',
409
+ '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.',
390
410
  {
391
411
  prompt: z.string().describe('Text description of the desired video / animation'),
392
- 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 elementsMaxImages / elementsMaxVideos / elementsMaxAudio on the model. Omit for Smart Select.'),
393
- reference_images: z.array(z.string()).optional().describe('Array of public image URLs used as reference elements (product shots, character references, etc.). Check elementsMaxImages on the chosen model — pass at most that many URLs.'),
394
- reference_videos: z.array(z.string()).optional().describe('Array of reference video URLs for models that accept video inputs (elementsMaxVideos > 0). Check elementsMaxVideos on the chosen model from list_models before passing.'),
395
- audio_url: z.string().optional().describe('URL of a reference audio track for models that accept audio inputs (elementsMaxAudio > 0). Check elementsMaxAudio on the chosen model from list_models before passing.'),
412
+ 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.'),
413
+ 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.'),
414
+ 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.'),
415
+ 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.'),
396
416
  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.'),
397
417
  duration: z.number().optional().describe('Duration in seconds. Default: 5'),
398
418
  aspect_ratio: z.string().optional().describe('Aspect ratio (e.g., "16:9", "9:16", "1:1"). Default: "16:9"'),
@@ -442,6 +462,7 @@ function registerGenerateTools(server, client) {
442
462
  content: [{
443
463
  type: 'text',
444
464
  text: JSON.stringify({
465
+ ...creditFields(result),
445
466
  urls: result.result?.urls || [],
446
467
  thumbnail_url: result.result?.thumbnail_url || null,
447
468
  duration: result.result?.duration || null,
@@ -511,6 +532,7 @@ function registerGenerateTools(server, client) {
511
532
  content: [{
512
533
  type: 'text',
513
534
  text: JSON.stringify({
535
+ ...creditFields(result),
514
536
  urls: result.result?.urls || [],
515
537
  thumbnail_url: result.result?.thumbnail_url || null,
516
538
  duration: result.result?.duration || null,
@@ -581,6 +603,7 @@ function registerGenerateTools(server, client) {
581
603
  content: [{
582
604
  type: 'text',
583
605
  text: JSON.stringify({
606
+ ...creditFields(result),
584
607
  urls: result.result?.urls || [],
585
608
  thumbnail_url: result.result?.thumbnail_url || null,
586
609
  duration: result.result?.duration || null,
@@ -594,19 +617,19 @@ function registerGenerateTools(server, client) {
594
617
  // ─── generate_video_from_video ─────────────────────────────
595
618
  server.tool(
596
619
  'generate_video_from_video',
597
- '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 maxImages / maxVideos / maxElements on the chosen model before generating. Pass reference_images for models with maxImages > 0 (e.g. Kling O1/O3, Aleph, WAN VACE), reference_videos for models with maxVideos > 1 (e.g. WAN 2.6 reference-to-video accepts up to 3), and elements for models with maxElements > 0. For animating a still image use generate_video_from_image instead. For text-only → video use generate_video.',
620
+ '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.',
598
621
  {
599
622
  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.'),
600
623
  prompt: z.string().describe('Text description of the desired restyle / transformation'),
601
- model: z.string().optional().describe('Model identifier. Use list_models type="video_to_video" to see options and check maxImages / maxVideos / maxElements per model. Omit for Smart Select.'),
624
+ 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.'),
602
625
  aspect_ratio: z.string().optional().describe('Output aspect ratio. Default: matches source'),
603
626
  duration: z.number().optional().describe('Duration in seconds (default: matches source)'),
604
627
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: true'),
605
628
  visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply for character/style consistency.'),
606
629
  resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Model-dependent — call list_models and read supported_resolutions.'),
607
- reference_images: z.array(z.string()).optional().describe('Array of reference image URLs for models that support additional image inputs (maxImages > 0). Examples: character reference images for Kling O1/O3, style reference for Aleph/gen4_aleph, character image for WAN VACE video-edit. Check maxImages on the model from list_models before passing.'),
608
- reference_videos: z.array(z.string()).optional().describe('Array of additional reference video URLs for models that support multiple video inputs (maxVideos > 1). Example: WAN 2.6 reference-to-video accepts 1–3 reference videos. Check maxVideos on the model from list_models before passing.'),
609
- elements: z.array(z.string()).optional().describe('Array of element image URLs for models with maxElements > 0. Elements are used as style or character reference assets alongside the main video. Check maxElements on the model from list_models before passing.')
630
+ 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.'),
631
+ 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.'),
632
+ 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.')
610
633
  },
611
634
  async ({ source_video, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution, reference_images, reference_videos, elements }) => {
612
635
  if (!source_video) throw new Error('source_video is required');
@@ -645,6 +668,7 @@ function registerGenerateTools(server, client) {
645
668
  content: [{
646
669
  type: 'text',
647
670
  text: JSON.stringify({
671
+ ...creditFields(result),
648
672
  urls: result.result?.urls || [],
649
673
  thumbnail_url: result.result?.thumbnail_url || null,
650
674
  duration: result.result?.duration || null,
@@ -685,6 +709,7 @@ function registerGenerateTools(server, client) {
685
709
  content: [{
686
710
  type: 'text',
687
711
  text: JSON.stringify({
712
+ ...creditFields(result),
688
713
  text: result.result?.text || '',
689
714
  srt_url: result.result?.srt_url || null,
690
715
  word_by_word_srt_url: result.result?.word_by_word_srt_url || null,
@@ -737,6 +762,7 @@ function registerGenerateTools(server, client) {
737
762
  content: [{
738
763
  type: 'text',
739
764
  text: JSON.stringify({
765
+ ...creditFields(result),
740
766
  urls: result.result?.urls || [],
741
767
  thumbnail_url: result.result?.thumbnail_url || null,
742
768
  mode: result.result?.mode || null,
@@ -778,6 +804,7 @@ function registerGenerateTools(server, client) {
778
804
  content: [{
779
805
  type: 'text',
780
806
  text: JSON.stringify({
807
+ ...creditFields(result),
781
808
  urls: result.result?.urls || [],
782
809
  edit_type: result.result?.edit_type || null,
783
810
  model: result.result?.model || null
@@ -826,6 +853,7 @@ function registerGenerateTools(server, client) {
826
853
  content: [{
827
854
  type: 'text',
828
855
  text: JSON.stringify({
856
+ ...creditFields(result),
829
857
  urls: result.result?.urls || [],
830
858
  download_url: result.result?.download_url || null,
831
859
  edit_type: result.result?.edit_type || null,
@@ -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) {
@@ -57,6 +113,38 @@ function registerModelTools(server, client) {
57
113
  };
58
114
  }
59
115
  );
116
+
117
+ // ─── get_session_usage ─────────────────────────────────────
118
+ // Real, multiplier-adjusted credit spend tagged with the caller's
119
+ // X-Kolbo-Caller-Session-Id (set automatically by the parent process —
120
+ // no need to pass it). Use this to give the user an honest "you've spent
121
+ // X credits in this app session" instead of estimating from base credits.
122
+ server.tool(
123
+ 'get_session_usage',
124
+ 'Fetch real, multiplier-adjusted credit spend for the current Kolbo Code app session. Use when the user asks "how much did I spend?" or before/after a large bulk job so you can quote actual cost (not an estimate from base credits). Returns total + per-tool breakdown + per-model breakdown + a recent list. The caller-session-id is forwarded automatically by the MCP HTTP client.',
125
+ {},
126
+ async () => {
127
+ try {
128
+ const r = await client.get('/credit-usage/by-caller-session');
129
+ // The endpoint returns { message, data: { total, count, by_tool, by_model, recent[] } }
130
+ return {
131
+ content: [{
132
+ type: 'text',
133
+ text: JSON.stringify(r.data || r, null, 2)
134
+ }]
135
+ };
136
+ } catch (err) {
137
+ // 400 from the endpoint means no caller-session-id was forwarded —
138
+ // surface a clear hint instead of a generic API error.
139
+ const hint = err?.status === 400
140
+ ? 'No caller-session-id was forwarded. Ensure the parent process (Kolbo Code / desktop sidecar) sets KOLBO_CALLER_SESSION_ID in this MCP\'s env, or call again after at least one media generation has fired.'
141
+ : err?.message || 'Failed to fetch session usage';
142
+ return {
143
+ content: [{ type: 'text', text: JSON.stringify({ error: hint }, null, 2) }]
144
+ };
145
+ }
146
+ }
147
+ );
60
148
  }
61
149
 
62
150
  module.exports = { registerModelTools };