@kolbo/mcp 1.6.8 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,7 +43,7 @@ Just ask Claude naturally:
43
43
  - *"What's in this image?"*
44
44
  - *"Create a Visual DNA profile called 'Alex' from these images"*
45
45
 
46
- ## Available Tools (30)
46
+ ## Available Tools (51)
47
47
 
48
48
  **Generation**
49
49
  | Tool | Description |
@@ -92,7 +92,28 @@ Every generation tool also accepts an optional `resolution` arg. Images use `"1K
92
92
  | Tool | Description |
93
93
  |------|-------------|
94
94
  | `upload_media` | Upload a local file (or remote URL) → stable Kolbo CDN URL for reuse |
95
- | `list_media` | Browse your uploaded media with type filter and pagination |
95
+ | `list_media` | Browse media library filter by `project_id`, `folder_id`, `type`, `category` (ai / uploaded / edited / favorites / training-lab), `source_type`, `sort`, `search`, pagination |
96
+ | `list_media_folders` | List the user's media folders (owned + shared) — discover `folder_id` values to pass to `list_media` |
97
+ | `create_media_folder` | Create a new folder (name, optional description / color / icon) |
98
+ | `update_media_folder` | Rename / recolor / re-icon a folder (owner only) |
99
+ | `delete_media_folder` | Soft-delete a folder (owner only; items remain in library) |
100
+ | `add_media_to_folder` | Add up to 500 media items to a folder (idempotent) |
101
+ | `remove_media_from_folder` | Remove media items from a folder |
102
+ | `share_media_folder` | Share a folder by user email (owner only) |
103
+ | `unshare_media_folder` | Revoke a user's access to a folder (owner only) |
104
+ | `favorite_media` | Mark a media item as favorited (idempotent) — pass `media_id` from `list_media` |
105
+ | `unfavorite_media` | Remove a media item from favorites (idempotent) — pass `media_id` from `list_media` |
106
+ | `get_media` | Fetch one media item's full details by id |
107
+ | `delete_media` | Soft-delete a media item (30-day trash) |
108
+ | `restore_media` | Restore a trashed item |
109
+ | `permanently_delete_media` | Hard-delete (NOT reversible — confirm with user first) |
110
+ | `move_media` | Re-assign a media item to a different project |
111
+ | `bulk_delete_media` | Soft-delete up to 1000 items in one call |
112
+ | `bulk_restore_media` | Restore up to 1000 trashed items |
113
+ | `bulk_permanently_delete_media` | Hard-delete up to 1000 (NOT reversible) |
114
+ | `bulk_move_media` | Move up to 1000 items to a project (atomic — all-or-nothing) |
115
+ | `move_folder_contents` | Move every item in a folder to a project |
116
+ | `get_media_stats` | Counts + storage bytes per type (optionally per project) |
96
117
 
97
118
  **Discovery & Account**
98
119
  | Tool | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.6.8",
3
+ "version": "1.12.0",
4
4
  "description": "Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/client.js CHANGED
@@ -151,10 +151,12 @@ class KolboClient {
151
151
  this.apiKey = this._envKey || this._readAuthStore();
152
152
 
153
153
  if (!this.apiKey) {
154
+ // No key in env OR auth store. The Kolbo Code parent process should
155
+ // never spawn us in this state (it injects the key into env after the
156
+ // user signs in). If this fires, the parent will catch it via the
157
+ // [KOLBO_AUTH_MISSING] tag and surface the in-app sign-in flow.
154
158
  throw new Error(
155
- 'Kolbo API key not found.\n' +
156
- 'Fix: Run "kolbo auth login" in the terminal, then restart this editor.\n' +
157
- 'Or: Get an API key at https://app.kolbo.ai/developer and set KOLBO_API_KEY env var.'
159
+ 'Kolbo API key not found. Sign in to Kolbo to continue. [KOLBO_AUTH_MISSING]'
158
160
  );
159
161
  }
160
162
  }
@@ -198,12 +200,21 @@ class KolboClient {
198
200
 
199
201
  async _doRequest(method, reqPath, body = null) {
200
202
  const url = `${this.baseUrl}${reqPath}`;
203
+ const headers = {
204
+ 'X-API-Key': this.apiKey,
205
+ 'Content-Type': 'application/json'
206
+ };
207
+ // Stable per-app-launch identifier from the parent process (Kolbo Code
208
+ // sets this in the MCP env when spawning us). kolbo-api tags every
209
+ // CreditUsage record with it so the desktop UI and the get_session_usage
210
+ // tool can aggregate spend without enumerating individual generations.
211
+ const callerSessionId = process.env.KOLBO_CALLER_SESSION_ID;
212
+ if (callerSessionId) {
213
+ headers['X-Kolbo-Caller-Session-Id'] = callerSessionId;
214
+ }
201
215
  const options = {
202
216
  method,
203
- headers: {
204
- 'X-API-Key': this.apiKey,
205
- 'Content-Type': 'application/json'
206
- }
217
+ headers,
207
218
  };
208
219
 
209
220
  if (body) {
@@ -226,9 +237,15 @@ class KolboClient {
226
237
  const code = data.code || null;
227
238
  let fullMessage = code ? `${message} [${code}]` : message;
228
239
  if (response.status === 401) {
229
- // Tag the response so the retry logic in request() can see it
240
+ // Tag the response so the retry logic in request() can see it AND so
241
+ // the Kolbo Code parent process can intercept this error before the
242
+ // agent sees it — trigger the in-app reconnect flow, refresh the key,
243
+ // and transparently retry the tool call. Never instruct the user to
244
+ // open a terminal: most users run Kolbo Code as a desktop / web app
245
+ // and have no terminal context.
230
246
  data._status = 401;
231
- fullMessage += '\n\nAPI key is invalid or expired. Fix: run "kolbo auth login" in the terminal, then restart this editor. Or get a new key at https://app.kolbo.ai/developer';
247
+ data._kolbo_auth_expired = true;
248
+ fullMessage = `${fullMessage} [KOLBO_AUTH_EXPIRED]`;
232
249
  }
233
250
  throw new KolboApiError(fullMessage, {
234
251
  code,
@@ -252,8 +269,12 @@ class KolboClient {
252
269
  return this.request('PUT', reqPath, body);
253
270
  }
254
271
 
255
- async delete(reqPath) {
256
- return this.request('DELETE', reqPath);
272
+ async patch(reqPath, body = null) {
273
+ return this.request('PATCH', reqPath, body);
274
+ }
275
+
276
+ async delete(reqPath, body = null) {
277
+ return this.request('DELETE', reqPath, body);
257
278
  }
258
279
 
259
280
  async postMultipart(reqPath, formData) {
@@ -270,6 +291,11 @@ class KolboClient {
270
291
  'X-API-Key': this.apiKey,
271
292
  ...formData.getHeaders()
272
293
  };
294
+ // Same caller-session header as JSON requests — see _doRequest.
295
+ const callerSessionId = process.env.KOLBO_CALLER_SESSION_ID;
296
+ if (callerSessionId) {
297
+ headers['X-Kolbo-Caller-Session-Id'] = callerSessionId;
298
+ }
273
299
 
274
300
  // Serialize form-data to a Buffer before passing to fetch(). Node's
275
301
  // built-in fetch (undici) can't consume legacy Node.js streams from
@@ -298,8 +324,12 @@ class KolboClient {
298
324
  const code = data.code || null;
299
325
  let fullMessage = code ? `${message} [${code}]` : message;
300
326
  if (response.status === 401) {
327
+ // Multipart uploads: same auth-expired contract as _doRequest. The
328
+ // Kolbo Code parent process intercepts [KOLBO_AUTH_EXPIRED] and runs
329
+ // the in-app reconnect flow — no terminal command needed.
301
330
  data._status = 401;
302
- fullMessage += '\n\nAPI key is invalid or expired. Fix: run "kolbo auth login" in the terminal, then restart this editor. Or get a new key at https://app.kolbo.ai/developer';
331
+ data._kolbo_auth_expired = true;
332
+ fullMessage = `${fullMessage} [KOLBO_AUTH_EXPIRED]`;
303
333
  }
304
334
  throw new KolboApiError(fullMessage, {
305
335
  code,
package/src/index.js CHANGED
@@ -67,6 +67,7 @@ const { registerMoodboardTools } = require('./tools/moodboards');
67
67
  const { registerMediaTools } = require('./tools/media');
68
68
  const { registerPresetTools } = require('./tools/presets');
69
69
  const { registerAppBuilderTools } = require('./tools/app_builder');
70
+ const { registerArtifactTools } = require('./tools/artifacts');
70
71
 
71
72
  async function main() {
72
73
  const client = new KolboClient();
@@ -85,6 +86,7 @@ async function main() {
85
86
  registerMediaTools(server, client);
86
87
  registerPresetTools(server, client);
87
88
  registerAppBuilderTools(server, client);
89
+ registerArtifactTools(server, client);
88
90
 
89
91
  // Start the server with stdio transport
90
92
  const transport = new StdioServerTransport();
@@ -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
  };
@@ -0,0 +1,60 @@
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
+
8
+ function registerArtifactTools(server, client) {
9
+ // ─── publish_html_artifact ─────────────────────────────────────
10
+ server.tool(
11
+ 'publish_html_artifact',
12
+ 'Publish an HTML page (or SVG / Mermaid diagram) to kolbo.ai and return a public shareable URL. Use this when the user explicitly asks to share, publish, or deploy a built artifact so they can send the URL to someone. The content is hosted at https://sites.kolbo.ai/<slug>; the page is served with restrictive CSP (no fetch/XHR/form-action) so it cannot exfiltrate data. Identical content uploaded twice returns the same URL (server dedup).',
13
+ {
14
+ title: z.string().describe('Human-friendly title for the page (also used to generate the SEO slug). Keep under ~60 chars.'),
15
+ content: z.string().describe('The raw artifact body. For type="html" this is a full HTML document (DOCTYPE + html/head/body). For "svg" it is an <svg> document. For "mermaid" it is the Mermaid source text.'),
16
+ type: z.enum(['html', 'svg', 'mermaid']).optional().describe('Artifact type. Default: "html".'),
17
+ allow_js: z.boolean().optional().describe('Allow inline <script> execution on the published page. Default: false. Required for Tailwind JIT, Chart.js, Three.js, React-from-CDN etc.'),
18
+ },
19
+ async ({ title, content, type, allow_js }) => {
20
+ if (!title || !title.trim()) throw new Error('title is required');
21
+ if (typeof content !== 'string' || !content.length) throw new Error('content is required');
22
+
23
+ const result = await client.post('/artifact/quick-share', {
24
+ title: title.trim(),
25
+ content,
26
+ type: type || 'html',
27
+ allowJs: allow_js === true,
28
+ });
29
+
30
+ const artifact = result?.data || {};
31
+ const slug = artifact.shareableSlug || artifact.shareToken;
32
+ // Compose env-correct URL. sites.kolbo.ai only resolves in prod; for
33
+ // dev/staging we serve straight from the kolbo-api host.
34
+ const apiBase = client.baseUrl || 'https://api.kolbo.ai/api';
35
+ const isProd = /(^|\/\/)api\.kolbo\.ai/i.test(apiBase);
36
+ let url;
37
+ if (isProd) {
38
+ url = artifact.siteUrl || (slug ? `https://sites.kolbo.ai/${slug}` : null);
39
+ }
40
+ if (!url && artifact.shareToken) {
41
+ url = `${apiBase}/shared-artifact-raw/${artifact.shareToken}`;
42
+ }
43
+
44
+ return {
45
+ content: [{
46
+ type: 'text',
47
+ text: JSON.stringify({
48
+ url,
49
+ shareToken: artifact.shareToken,
50
+ shareableSlug: slug,
51
+ duplicate: result?.duplicate === true,
52
+ title: artifact.title,
53
+ }),
54
+ }],
55
+ };
56
+ },
57
+ );
58
+ }
59
+
60
+ module.exports = { registerArtifactTools };
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 ────────────────────────────────────────
@@ -16,11 +16,11 @@ function registerGenerateTools(server, client) {
16
16
  {
17
17
  prompt: z.string().describe('Text description of the image to generate'),
18
18
  model: z.string().optional().describe('Model identifier. Use list_models type="text_to_img" to see options. Omit for Smart Select.'),
19
- aspect_ratio: z.string().optional().describe('Aspect ratio (e.g., "1:1", "16:9", "9:16"). Default: "1:1"'),
19
+ aspect_ratio: z.string().optional().describe('Aspect ratio (e.g., "1:1", "16:9", "9:16"). Must be a value present in the model\'s `supported_aspect_ratios` from list_models — pass an unsupported value and the API rejects. Default: "1:1"'),
20
20
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt for better results. Default: true'),
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('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.'),
21
+ num_images: z.number().optional().describe('Number of images to generate in one call. Default: 1. Note: some models (Midjourney etc.) have a fixed `images_per_request` and ignore this — check list_models.'),
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. **Cap: pass at most `max_reference_images` URLs from list_models for the chosen model — exceeding it is a deterministic 400.** 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. **Cap: pass at most `max_visual_dna` IDs from list_models — if the field is null/0 or `supports_visual_dna: false`, the model rejects DNA entirely (silently ignored in some paths).** 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). 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
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.'),
@@ -41,6 +41,7 @@ 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
47
  prompt_used: result.result.prompt_used,
@@ -58,11 +59,11 @@ function registerGenerateTools(server, client) {
58
59
  {
59
60
  prompt: z.string().describe('Description of the edit to apply (e.g., "remove the background", "change the sky to sunset")'),
60
61
  model: z.string().optional().describe('Model identifier. Use list_models type="image_editing" to see options. Omit for Smart Select.'),
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.'),
62
- aspect_ratio: z.string().optional().describe('Output aspect ratio (e.g., "1:1", "16:9", "9:16"). Default: "1:1"'),
62
+ source_images: z.array(z.string()).describe('PIXEL-ACCURATE compositing. Array of source image URLs whose pixel content is composited into the output. **Cap: pass at most `max_reference_images` URLs from list_models for the chosen model — exceeding it is a deterministic 400.** 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") or use @image1/@image2 tags. Add "composite AS-IS, do not redraw or restyle" to lock pixels.'),
63
+ aspect_ratio: z.string().optional().describe('Output aspect ratio (e.g., "1:1", "16:9", "9:16"). Must be in the chosen model\'s `supported_aspect_ratios` from list_models. Default: "1:1"'),
63
64
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt for better results. Default: true'),
64
65
  num_images: z.number().optional().describe('Number of output images. Default: 1'),
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.'),
66
+ visual_dna_ids: z.array(z.string()).optional().describe('Visual DNA profile IDs for character / style / product consistency. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model.** 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.'),
66
67
  moodboard_id: z.string().optional().describe('Moodboard ID whose master_prompt and style_guide should be applied.'),
67
68
  enable_web_search: z.boolean().optional().describe('Enable web-search grounding. Default: false'),
68
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.')
@@ -86,6 +87,7 @@ function registerGenerateTools(server, client) {
86
87
  content: [{
87
88
  type: 'text',
88
89
  text: JSON.stringify({
90
+ ...creditFields(result),
89
91
  urls: result.result.urls,
90
92
  model: result.result.model,
91
93
  prompt_used: result.result.prompt_used,
@@ -104,12 +106,12 @@ function registerGenerateTools(server, client) {
104
106
  prompt: z.string().describe('Creative brief or concept describing the full set of scenes to generate'),
105
107
  scene_count: z.number().optional().describe('Number of scenes/images to generate, 1–8. Default: 4. Use this — NOT num_images — to control how many outputs are created.'),
106
108
  model: z.string().optional().describe('Model identifier applied to every scene. Omit for Smart Select.'),
107
- aspect_ratio: z.string().optional().describe('Aspect ratio applied to every scene (e.g., "1:1", "16:9", "9:16"). Default: "1:1"'),
109
+ aspect_ratio: z.string().optional().describe('Aspect ratio applied to every scene (e.g., "1:1", "16:9", "9:16"). Must be in the chosen model\'s `supported_aspect_ratios` from list_models. Default: "1:1"'),
108
110
  workflow_type: z.string().optional().describe('"image" (default) or "video"'),
109
- duration: z.number().optional().describe('Duration in seconds per scene (video mode only). E.g., 5 or 10.'),
111
+ duration: z.number().optional().describe('Duration in seconds per scene (video mode only). Must be a value in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration`. E.g., 5 or 10.'),
110
112
  enhance_prompt: z.boolean().optional().describe('Enhance prompts per scene. Default: true'),
111
- reference_images: z.array(z.string()).optional().describe('Array of reference image URLs to guide style/composition of every scene.'),
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.'),
113
+ reference_images: z.array(z.string()).optional().describe('Array of reference image URLs to guide style/composition of every scene. **Cap: pass at most `max_reference_images` URLs from list_models for the chosen model.**'),
114
+ visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply consistently across every scene. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model.** This is the ideal way to keep a character or product looking the same in all scenes of a campaign.'),
113
115
  moodboard_id: z.string().optional().describe('A single moodboard ID whose master_prompt and style_guide should shape every scene.'),
114
116
  moodboard_ids: z.array(z.string()).optional().describe('Multiple moodboard IDs when blending styles. Prefer `moodboard_id` for single moodboards.'),
115
117
  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.')
@@ -139,6 +141,7 @@ function registerGenerateTools(server, client) {
139
141
  content: [{
140
142
  type: 'text',
141
143
  text: JSON.stringify({
144
+ ...creditFields(result),
142
145
  scenes,
143
146
  total_scenes: result.scenes?.length || 0,
144
147
  completed_scenes: scenes.length,
@@ -160,10 +163,10 @@ function registerGenerateTools(server, client) {
160
163
  {
161
164
  prompt: z.string().describe('Text description of the video to generate'),
162
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.'),
163
- aspect_ratio: z.string().optional().describe('Aspect ratio (e.g., "16:9", "9:16", "1:1"). Default: "16:9"'),
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'),
166
+ aspect_ratio: z.string().optional().describe('Aspect ratio (e.g., "16:9", "9:16", "1:1"). Must be in the chosen model\'s `supported_aspect_ratios` from list_models. Default: "16:9"'),
167
+ duration: z.number().optional().describe('Duration in seconds. Must be a value in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration` (whichever the model exposes). Default: 5'),
165
168
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: true'),
166
- reference_images: z.array(z.string()).optional().describe('Array of image URLs used as visual references (style / composition / subject).'),
169
+ reference_images: z.array(z.string()).optional().describe('Array of image URLs used as visual references (style / composition / subject). **Cap: pass at most `max_reference_images` URLs from list_models for the chosen model — exceeding it is a deterministic 400.**'),
167
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.'),
168
171
  preset_id: z.string().optional().describe('Preset ID from list_presets type="video" to apply a saved motion/style preset to this generation.')
169
172
  },
@@ -181,6 +184,7 @@ function registerGenerateTools(server, client) {
181
184
  content: [{
182
185
  type: 'text',
183
186
  text: JSON.stringify({
187
+ ...creditFields(result),
184
188
  urls: result.result.urls,
185
189
  model: result.result.model,
186
190
  duration: result.result.duration,
@@ -201,10 +205,10 @@ function registerGenerateTools(server, client) {
201
205
  image_url: z.string().describe('URL of the source image to animate'),
202
206
  prompt: z.string().describe('Text description of the desired MOTION (e.g., "camera slowly pans right while the character walks forward")'),
203
207
  model: z.string().optional().describe('Model identifier. Use list_models type="img_to_video" to see options.'),
204
- aspect_ratio: z.string().optional().describe('Output aspect ratio (e.g., "16:9", "9:16", "1:1"). Default: "16:9"'),
205
- duration: z.number().optional().describe('Duration in seconds. Must be a value the chosen model supports. Default: 5'),
208
+ aspect_ratio: z.string().optional().describe('Output aspect ratio (e.g., "16:9", "9:16", "1:1"). Must be in the chosen model\'s `supported_aspect_ratios` from list_models. Default: "16:9"'),
209
+ duration: z.number().optional().describe('Duration in seconds. Must be in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration`. Default: 5'),
206
210
  enhance_prompt: z.boolean().optional().describe('Enhance the motion prompt. Default: true'),
207
- visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to maintain consistency with prior characters / styles.'),
211
+ visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to maintain consistency with prior characters / styles. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model; if `supports_visual_dna: false` the model ignores DNA entirely.**'),
208
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.')
209
213
  },
210
214
  async ({ image_url, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution }) => {
@@ -221,6 +225,7 @@ function registerGenerateTools(server, client) {
221
225
  content: [{
222
226
  type: 'text',
223
227
  text: JSON.stringify({
228
+ ...creditFields(result),
224
229
  urls: result.result.urls,
225
230
  model: result.result.model,
226
231
  duration: result.result.duration,
@@ -260,6 +265,7 @@ function registerGenerateTools(server, client) {
260
265
  content: [{
261
266
  type: 'text',
262
267
  text: JSON.stringify({
268
+ ...creditFields(result),
263
269
  urls: result.result.urls,
264
270
  title: result.result.title,
265
271
  duration: result.result.duration,
@@ -294,6 +300,7 @@ function registerGenerateTools(server, client) {
294
300
  content: [{
295
301
  type: 'text',
296
302
  text: JSON.stringify({
303
+ ...creditFields(result),
297
304
  urls: result.result.urls,
298
305
  voice: result.result.voice,
299
306
  duration: result.result.duration
@@ -327,6 +334,7 @@ function registerGenerateTools(server, client) {
327
334
  content: [{
328
335
  type: 'text',
329
336
  text: JSON.stringify({
337
+ ...creditFields(result),
330
338
  urls: result.result.urls,
331
339
  duration: result.result.duration
332
340
  }, null, 2)
@@ -402,16 +410,16 @@ function registerGenerateTools(server, client) {
402
410
  {
403
411
  prompt: z.string().describe('Text description of the desired video / animation'),
404
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.'),
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.'),
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.'),
409
- duration: z.number().optional().describe('Duration in seconds. Default: 5'),
410
- aspect_ratio: z.string().optional().describe('Aspect ratio (e.g., "16:9", "9:16", "1:1"). Default: "16:9"'),
413
+ reference_images: z.array(z.string()).optional().describe('Array of public image URLs used as reference elements (product shots, character references, etc.). **Cap: pass at most `elements_max_images` URLs from list_models for the chosen model — exceeding it is a deterministic 400.**'),
414
+ reference_videos: z.array(z.string()).optional().describe('Array of reference video URLs for models that accept video inputs. **Cap: pass at most `elements_max_videos` URLs from list_models — if the cap is 0 the model rejects videos.**'),
415
+ audio_url: z.string().optional().describe('URL of a reference audio track. **Audio constraints: `elements_max_audio` from list_models gates whether audio is accepted at all; audio duration must fall within `min_audio_duration`-`max_audio_duration`; format must be in `supported_audio_formats` (if specified).**'),
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. **Total count across files + reference_images still capped by `elements_max_images`.**'),
417
+ duration: z.number().optional().describe('Output duration in seconds. Must be in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration`. Default: 5'),
418
+ aspect_ratio: z.string().optional().describe('Aspect ratio (e.g., "16:9", "9:16", "1:1"). Must be in `supported_aspect_ratios` from list_models. Default: "16:9"'),
411
419
  motion: z.string().optional().describe('Motion style / intensity hint (optional)'),
412
420
  preset_id: z.string().optional().describe('Preset ID from list_presets type="video" (optional)'),
413
421
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: true'),
414
- visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply for character/style consistency across outputs.'),
422
+ visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply for character/style consistency across outputs. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model.**'),
415
423
  resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Model-dependent — call list_models and read supported_resolutions.')
416
424
  },
417
425
  async ({ prompt, model, reference_images, reference_videos, audio_url, files, duration, aspect_ratio, motion, preset_id, enhance_prompt, visual_dna_ids, resolution }) => {
@@ -454,6 +462,7 @@ function registerGenerateTools(server, client) {
454
462
  content: [{
455
463
  type: 'text',
456
464
  text: JSON.stringify({
465
+ ...creditFields(result),
457
466
  urls: result.result?.urls || [],
458
467
  thumbnail_url: result.result?.thumbnail_url || null,
459
468
  duration: result.result?.duration || null,
@@ -475,10 +484,10 @@ function registerGenerateTools(server, client) {
475
484
  last_frame: z.string().optional().describe('URL or absolute local path to the last frame (file mode — alternative to last_frame_url)'),
476
485
  prompt: z.string().optional().describe('Optional description of the desired motion between the two frames (e.g. "smooth camera dolly in")'),
477
486
  model: z.string().optional().describe('Model identifier. Use list_models type="firstlastgenerations" to see options. Omit for Smart Select.'),
478
- duration: z.number().optional().describe('Duration in seconds. Default: 5'),
479
- aspect_ratio: z.string().optional().describe('Aspect ratio (auto-detected from first frame if not provided). Default: "16:9"'),
487
+ duration: z.number().optional().describe('Duration in seconds. Must be in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration`. Default: 5'),
488
+ aspect_ratio: z.string().optional().describe('Aspect ratio (auto-detected from first frame if not provided). Must be in `supported_aspect_ratios` from list_models when set. Default: "16:9"'),
480
489
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: true'),
481
- visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply.'),
490
+ visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model; if `supports_visual_dna: false`, DNA is silently ignored.**'),
482
491
  resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Model-dependent — call list_models and read supported_resolutions.')
483
492
  },
484
493
  async ({ first_frame_url, last_frame_url, first_frame, last_frame, prompt, model, duration, aspect_ratio, enhance_prompt, visual_dna_ids, resolution }) => {
@@ -523,6 +532,7 @@ function registerGenerateTools(server, client) {
523
532
  content: [{
524
533
  type: 'text',
525
534
  text: JSON.stringify({
535
+ ...creditFields(result),
526
536
  urls: result.result?.urls || [],
527
537
  thumbnail_url: result.result?.thumbnail_url || null,
528
538
  duration: result.result?.duration || null,
@@ -536,10 +546,10 @@ function registerGenerateTools(server, client) {
536
546
  // ─── generate_lipsync ──────────────────────────────────────
537
547
  server.tool(
538
548
  'generate_lipsync',
539
- 'Lipsync an audio track to a source image or video. Both `source` (image or video) and `audio` can be provided as URLs or as absolute local file paths. Pass a text_prompt only if the model supports it (some lipsync models do character performance from a prompt). Returns a lipsynced video URL.',
549
+ 'Lipsync an audio track to a source image or video. Both `source` (image or video) and `audio` can be provided as URLs or as absolute local file paths. Pass a text_prompt only if the model supports it (some lipsync models do character performance from a prompt). **Validate before submitting: for `lipsync-video` sources, the input video duration must fall within `min_video_duration`-`max_video_duration` from list_models; audio duration must fall within `min_audio_duration`-`max_audio_duration` (and if `audio_max_follows_video_duration: true`, audio is also capped at the video duration); audio format must be in `supported_audio_formats` when specified.** Returns a lipsynced video URL.',
540
550
  {
541
- source: z.string().describe('URL or absolute local path to the source image or video (the face to animate)'),
542
- audio: z.string().describe('URL or absolute local path to the audio track (the voice to sync to)'),
551
+ source: z.string().describe('URL or absolute local path to the source image or video (the face to animate). For lipsync-video: duration must fall within `min_video_duration`-`max_video_duration` from list_models.'),
552
+ audio: z.string().describe('URL or absolute local path to the audio track (the voice to sync to). Duration must fall within `min_audio_duration`-`max_audio_duration` from list_models; format must be in `supported_audio_formats` (when set).'),
543
553
  text_prompt: z.string().optional().describe('Optional text prompt (for performance-capable models)'),
544
554
  model: z.string().optional().describe('Model identifier. Use list_models type="lipsync-image" or type="lipsync-video" to see options. Omit for Smart Select.'),
545
555
  bounding_box_target: z.array(z.number()).optional().describe('Optional bounding box [x, y, w, h] for multi-face inputs (Hedra Character3 style). Leave empty for single-face.')
@@ -593,6 +603,7 @@ function registerGenerateTools(server, client) {
593
603
  content: [{
594
604
  type: 'text',
595
605
  text: JSON.stringify({
606
+ ...creditFields(result),
596
607
  urls: result.result?.urls || [],
597
608
  thumbnail_url: result.result?.thumbnail_url || null,
598
609
  duration: result.result?.duration || null,
@@ -608,17 +619,17 @@ function registerGenerateTools(server, client) {
608
619
  'generate_video_from_video',
609
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.',
610
621
  {
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.'),
622
+ source_video: z.string().describe('URL or absolute local path to the primary source video to restyle. **Source duration must fall within `min_video_duration`-`max_video_duration` from list_models for the chosen model** — videos outside that range are rejected (or silently truncated by some upstream providers). 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.'),
612
623
  prompt: z.string().describe('Text description of the desired restyle / transformation'),
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.'),
614
- aspect_ratio: z.string().optional().describe('Output aspect ratio. Default: matches source'),
615
- duration: z.number().optional().describe('Duration in seconds (default: matches source)'),
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 / max_video_duration per model. Omit for Smart Select.'),
625
+ aspect_ratio: z.string().optional().describe('Output aspect ratio. Must be in `supported_aspect_ratios` from list_models when set. Default: matches source'),
626
+ duration: z.number().optional().describe('Output duration in seconds. Must be in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration`. Default: matches source'),
616
627
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: true'),
617
- visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply for character/style consistency.'),
628
+ visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply for character/style consistency. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model; if `supports_visual_dna: false`, DNA is silently ignored.**'),
618
629
  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.')
630
+ reference_images: z.array(z.string()).optional().describe('Array of reference image URLs for models that support additional image inputs. **Cap: pass at most `max_images` URLs from list_models — if `max_images === 0` the model does not accept image refs.** Examples: character reference images for Kling O1/O3, style reference for Aleph/gen4_aleph, character image for WAN VACE video-edit.'),
631
+ reference_videos: z.array(z.string()).optional().describe('Array of additional reference video URLs for models that support multiple video inputs. **Cap: pass at most `max_videos` URLs from list_models — if `max_videos <= 1` only the source_video is accepted.** Example: WAN 2.6 reference-to-video accepts 1–3 reference videos.'),
632
+ elements: z.array(z.string()).optional().describe('Array of element image URLs. **Cap: pass at most `max_elements` URLs from list_models — if `max_elements === 0` the model does not accept elements.** Elements are style or character reference assets alongside the main video.')
622
633
  },
623
634
  async ({ source_video, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution, reference_images, reference_videos, elements }) => {
624
635
  if (!source_video) throw new Error('source_video is required');
@@ -657,6 +668,7 @@ function registerGenerateTools(server, client) {
657
668
  content: [{
658
669
  type: 'text',
659
670
  text: JSON.stringify({
671
+ ...creditFields(result),
660
672
  urls: result.result?.urls || [],
661
673
  thumbnail_url: result.result?.thumbnail_url || null,
662
674
  duration: result.result?.duration || null,
@@ -697,6 +709,7 @@ function registerGenerateTools(server, client) {
697
709
  content: [{
698
710
  type: 'text',
699
711
  text: JSON.stringify({
712
+ ...creditFields(result),
700
713
  text: result.result?.text || '',
701
714
  srt_url: result.result?.srt_url || null,
702
715
  word_by_word_srt_url: result.result?.word_by_word_srt_url || null,
@@ -749,6 +762,7 @@ function registerGenerateTools(server, client) {
749
762
  content: [{
750
763
  type: 'text',
751
764
  text: JSON.stringify({
765
+ ...creditFields(result),
752
766
  urls: result.result?.urls || [],
753
767
  thumbnail_url: result.result?.thumbnail_url || null,
754
768
  mode: result.result?.mode || null,
@@ -790,6 +804,7 @@ function registerGenerateTools(server, client) {
790
804
  content: [{
791
805
  type: 'text',
792
806
  text: JSON.stringify({
807
+ ...creditFields(result),
793
808
  urls: result.result?.urls || [],
794
809
  edit_type: result.result?.edit_type || null,
795
810
  model: result.result?.model || null
@@ -838,6 +853,7 @@ function registerGenerateTools(server, client) {
838
853
  content: [{
839
854
  type: 'text',
840
855
  text: JSON.stringify({
856
+ ...creditFields(result),
841
857
  urls: result.result?.urls || [],
842
858
  download_url: result.result?.download_url || null,
843
859
  edit_type: result.result?.edit_type || null,
@@ -45,19 +45,29 @@ function registerMediaTools(server, client) {
45
45
  // ─── list_media ────────────────────────────────────────────
46
46
  server.tool(
47
47
  'list_media',
48
- 'List the user\'s uploaded media from their Kolbo media library. Supports filtering by type (image / video / audio) and pagination. Returns items with stable URLs, names, sizes, and upload timestamps. Use this to discover what the user has previously uploaded before deciding whether to create new content.',
48
+ 'Browse the user\'s Kolbo media library both uploaded files AND AI-generated outputs they have saved. Powerful filtering: scope to a single project (`project_id`), a user folder (`folder_id`), a "section" / category (`category`: ai / uploaded / edited / favorites / training-lab), a media type (`type`: image / video / audio), or generation provenance (`source_type`). Combine filters freely. Use this to discover what the user already has before generating something new, to retrieve a specific past creation, or to list everything in a project for downstream batch work.',
49
49
  {
50
- type: z.string().optional().describe('Filter by type: "image" | "video" | "audio". Omit for all types.'),
51
- page: z.number().optional().describe('Page number (1-indexed). Default: 1'),
52
- page_size: z.number().optional().describe('Items per page. Default: 20, max 100'),
53
- search: z.string().optional().describe('Optional full-text search term matched against media names and descriptions')
50
+ project_id: z.string().optional().describe('Restrict to a single project (Mongo ObjectId). Use `app_builder_list_projects` to discover IDs. Omit to list across all the user\'s media.'),
51
+ folder_id: z.string().optional().describe('Restrict to a user folder (Mongo ObjectId). Discover folder IDs via `list_media_folders`. Takes precedence over project_id when both are set.'),
52
+ type: z.enum(['image', 'video', 'audio', 'all']).optional().describe('Filter by media type. Default: all types.'),
53
+ category: z.enum(['ai', 'uploaded', 'edited', 'favorites', 'training-lab', 'all']).optional().describe('Filter by "section" (matches the Kolbo desktop app sidebar): `ai` = AI-generated, `uploaded` = files the user uploaded, `edited` = AI-edited variants, `favorites` = items the user starred, `training-lab` = training-lab assets. Default: all sections.'),
54
+ source_type: z.enum(['uploaded', 'generated', 'chat-generated']).optional().describe('Lower-level provenance filter. Use `category` for the common case; use `source_type` for fine-grained distinction (e.g. only chat-generated images).'),
55
+ sort: z.enum(['created_desc', 'created_asc', 'name_asc', 'name_desc']).optional().describe('Sort order. Default: created_desc (newest first).'),
56
+ page: z.number().optional().describe('1-indexed page number. Default: 1'),
57
+ page_size: z.number().optional().describe('Items per page. Default: 50, max 200.'),
58
+ search: z.string().optional().describe('Free-text match against filename + original prompt.')
54
59
  },
55
- async ({ type, page, page_size, search }) => {
60
+ async ({ project_id, folder_id, type, category, source_type, sort, page, page_size, search }) => {
56
61
  const params = new URLSearchParams();
57
- if (type) params.set('type', type);
58
- if (page) params.set('page', String(page));
59
- if (page_size) params.set('pageSize', String(page_size));
60
- if (search) params.set('searchTerm', search);
62
+ if (project_id) params.set('project_id', project_id);
63
+ if (folder_id) params.set('folder_id', folder_id);
64
+ if (type) params.set('type', type);
65
+ if (category) params.set('category', category);
66
+ if (source_type) params.set('source_type', source_type);
67
+ if (sort) params.set('sort', sort);
68
+ if (page) params.set('page', String(page));
69
+ if (page_size) params.set('page_size', String(page_size));
70
+ if (search) params.set('search', search);
61
71
 
62
72
  const qs = params.toString();
63
73
  const result = await client.get(`/v1/media${qs ? '?' + qs : ''}`);
@@ -73,6 +83,334 @@ function registerMediaTools(server, client) {
73
83
  };
74
84
  }
75
85
  );
86
+
87
+ // ─── favorite_media ────────────────────────────────────────
88
+ server.tool(
89
+ 'favorite_media',
90
+ 'Mark a media item as a favorite for the user. Idempotent — calling on an already-favorited item is a no-op. Requires the media `id` from `list_media`. After favoriting, the item shows up in `list_media` with `category=favorites` and in the desktop app sidebar\'s Favorites section. Use this when the user explicitly says "favorite this", "save this to favorites", "star this", or similar.',
91
+ {
92
+ media_id: z.string().describe('The MediaLibraryItem id (returned as `id` from `list_media`).')
93
+ },
94
+ async ({ media_id }) => {
95
+ const result = await client.post(`/v1/media/${encodeURIComponent(media_id)}/favorite`, {});
96
+ return {
97
+ content: [{
98
+ type: 'text',
99
+ text: JSON.stringify(result, null, 2)
100
+ }]
101
+ };
102
+ }
103
+ );
104
+
105
+ // ─── unfavorite_media ──────────────────────────────────────
106
+ server.tool(
107
+ 'unfavorite_media',
108
+ 'Remove a media item from the user\'s favorites. Idempotent — calling on an item that isn\'t favorited is a no-op. Requires the media `id` from `list_media`. Use this when the user says "unfavorite", "remove from favorites", "unstar", or similar.',
109
+ {
110
+ media_id: z.string().describe('The MediaLibraryItem id (returned as `id` from `list_media`).')
111
+ },
112
+ async ({ media_id }) => {
113
+ const result = await client.delete(`/v1/media/${encodeURIComponent(media_id)}/favorite`);
114
+ return {
115
+ content: [{
116
+ type: 'text',
117
+ text: JSON.stringify(result, null, 2)
118
+ }]
119
+ };
120
+ }
121
+ );
122
+
123
+ // ─── list_media_folders ────────────────────────────────────
124
+ server.tool(
125
+ 'list_media_folders',
126
+ 'List the user\'s media folders (their own + folders shared with them). Folders are user-scoped and can span multiple projects — they\'re a way for the user to group media across the library independent of project structure. Use this to discover folder IDs to pass into `list_media` via `folder_id`, or to show the user what folders exist before suggesting where to look.',
127
+ {},
128
+ async () => {
129
+ const result = await client.get('/v1/media/folders');
130
+ return {
131
+ content: [{
132
+ type: 'text',
133
+ text: JSON.stringify({
134
+ folders: result.folders || [],
135
+ count: result.count || 0
136
+ }, null, 2)
137
+ }]
138
+ };
139
+ }
140
+ );
141
+
142
+ // ─── create_media_folder ───────────────────────────────────
143
+ server.tool(
144
+ 'create_media_folder',
145
+ 'Create a new media folder for the user. Folders are user-scoped (span all projects) and useful for grouping related assets. Returns the new folder id — pass it as `folder_id` to `list_media`, `add_media_to_folder`, etc.',
146
+ {
147
+ name: z.string().describe('Folder name (1–100 characters).'),
148
+ description: z.string().optional().describe('Optional description (up to 500 characters).'),
149
+ color: z.string().optional().describe('Optional hex color like "#3B82F6" for UI tinting. Default: Kolbo blue.'),
150
+ icon: z.string().optional().describe('Optional Lucide icon name (e.g. "folder", "star", "image"). Default: "folder".')
151
+ },
152
+ async ({ name, description, color, icon }) => {
153
+ const result = await client.post('/v1/media/folders', { name, description, color, icon });
154
+ return { content: [{ type: 'text', text: JSON.stringify(result.folder || result, null, 2) }] };
155
+ }
156
+ );
157
+
158
+ // ─── update_media_folder ───────────────────────────────────
159
+ server.tool(
160
+ 'update_media_folder',
161
+ 'Rename a folder or update its color / icon / description. Owner only. Any subset of fields may be provided — fields omitted are left unchanged.',
162
+ {
163
+ folder_id: z.string().describe('Folder id from `list_media_folders` or `create_media_folder`.'),
164
+ name: z.string().optional().describe('New folder name (1–100 characters).'),
165
+ description: z.string().optional().describe('New description (up to 500 characters). Pass "" to clear.'),
166
+ color: z.string().optional().describe('New hex color like "#3B82F6".'),
167
+ icon: z.string().optional().describe('New Lucide icon name.')
168
+ },
169
+ async ({ folder_id, name, description, color, icon }) => {
170
+ const body = {};
171
+ if (name !== undefined) body.name = name;
172
+ if (description !== undefined) body.description = description;
173
+ if (color !== undefined) body.color = color;
174
+ if (icon !== undefined) body.icon = icon;
175
+ const result = await client.put(`/v1/media/folders/${encodeURIComponent(folder_id)}`, body);
176
+ return { content: [{ type: 'text', text: JSON.stringify(result.folder || result, null, 2) }] };
177
+ }
178
+ );
179
+
180
+ // ─── delete_media_folder ───────────────────────────────────
181
+ server.tool(
182
+ 'delete_media_folder',
183
+ 'Delete a folder (soft delete — items inside are detached but NOT deleted from the user\'s media library). Owner only. ALWAYS ask the user to confirm before calling this — folder deletion is not surfaced in any "undo" flow.',
184
+ {
185
+ folder_id: z.string().describe('Folder id to delete.')
186
+ },
187
+ async ({ folder_id }) => {
188
+ const result = await client.delete(`/v1/media/folders/${encodeURIComponent(folder_id)}`);
189
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
190
+ }
191
+ );
192
+
193
+ // ─── add_media_to_folder ───────────────────────────────────
194
+ server.tool(
195
+ 'add_media_to_folder',
196
+ 'Add one or more media items to a folder. Caller must own the folder or be a shared member. Idempotent — items already in the folder are skipped silently. Up to 500 items per call.',
197
+ {
198
+ folder_id: z.string().describe('Target folder id.'),
199
+ media_ids: z.array(z.string()).describe('Array of MediaLibraryItem ids (from `list_media`). Up to 500.')
200
+ },
201
+ async ({ folder_id, media_ids }) => {
202
+ const result = await client.post(
203
+ `/v1/media/folders/${encodeURIComponent(folder_id)}/items`,
204
+ { media_ids }
205
+ );
206
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
207
+ }
208
+ );
209
+
210
+ // ─── remove_media_from_folder ──────────────────────────────
211
+ server.tool(
212
+ 'remove_media_from_folder',
213
+ 'Remove one or more media items from a folder. Caller must own the folder or be a shared member. Items themselves remain in the library. Up to 500 items per call.',
214
+ {
215
+ folder_id: z.string().describe('Folder id.'),
216
+ media_ids: z.array(z.string()).describe('Array of MediaLibraryItem ids to remove from the folder.')
217
+ },
218
+ async ({ folder_id, media_ids }) => {
219
+ const result = await client.delete(
220
+ `/v1/media/folders/${encodeURIComponent(folder_id)}/items`,
221
+ { media_ids }
222
+ );
223
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
224
+ }
225
+ );
226
+
227
+ // ─── share_media_folder ────────────────────────────────────
228
+ server.tool(
229
+ 'share_media_folder',
230
+ 'Share a folder with one or more users by email. Owner only. Users must already have a Kolbo account; emails not found are returned in `not_found`. Shared members can list folder contents, add and remove items, but cannot delete the folder or reshare it.',
231
+ {
232
+ folder_id: z.string().describe('Folder id to share.'),
233
+ user_emails: z.array(z.string()).describe('Array of email addresses to grant access to. Up to 50 per call.')
234
+ },
235
+ async ({ folder_id, user_emails }) => {
236
+ const result = await client.post(
237
+ `/v1/media/folders/${encodeURIComponent(folder_id)}/share`,
238
+ { user_emails }
239
+ );
240
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
241
+ }
242
+ );
243
+
244
+ // ─── get_media ─────────────────────────────────────────────
245
+ server.tool(
246
+ 'get_media',
247
+ 'Fetch one media item\'s full details by id. Returns the same shape as items in `list_media` plus extra metadata. Use this when the user references a specific item ("tell me about this generation", "what prompt did I use for [item]").',
248
+ {
249
+ media_id: z.string().describe('MediaLibraryItem id (from `list_media`). Generation ids are also accepted as a fallback.')
250
+ },
251
+ async ({ media_id }) => {
252
+ const result = await client.get(`/v1/media/${encodeURIComponent(media_id)}`);
253
+ return { content: [{ type: 'text', text: JSON.stringify(result.media || result, null, 2) }] };
254
+ }
255
+ );
256
+
257
+ // ─── delete_media ──────────────────────────────────────────
258
+ server.tool(
259
+ 'delete_media',
260
+ 'Soft-delete a media item — moves it to the user\'s trash where it can be restored for 30 days. Owner only. Idempotent. Use this for "delete this image / video / song" — NOT for `permanently_delete_media`, which is irreversible.',
261
+ {
262
+ media_id: z.string().describe('MediaLibraryItem id to soft-delete.')
263
+ },
264
+ async ({ media_id }) => {
265
+ const result = await client.delete(`/v1/media/${encodeURIComponent(media_id)}`);
266
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
267
+ }
268
+ );
269
+
270
+ // ─── restore_media ─────────────────────────────────────────
271
+ server.tool(
272
+ 'restore_media',
273
+ 'Restore a soft-deleted (trashed) media item back to the user\'s active library. Owner only. Use after `delete_media` if the user changes their mind, or when the user explicitly asks "restore [item] from trash".',
274
+ {
275
+ media_id: z.string().describe('MediaLibraryItem id to restore from trash.')
276
+ },
277
+ async ({ media_id }) => {
278
+ const result = await client.post(`/v1/media/${encodeURIComponent(media_id)}/restore`, {});
279
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
280
+ }
281
+ );
282
+
283
+ // ─── permanently_delete_media ──────────────────────────────
284
+ server.tool(
285
+ 'permanently_delete_media',
286
+ 'PERMANENTLY delete a media item — removes it from MongoDB, deletes the file from S3, removes from all folders, and deletes the source generation record. NOT REVERSIBLE — there is no recovery flow. Owner only. ALWAYS ask the user to explicitly confirm before calling this; use `delete_media` for normal "delete" intent.',
287
+ {
288
+ media_id: z.string().describe('MediaLibraryItem id to permanently delete. Cannot be undone.')
289
+ },
290
+ async ({ media_id }) => {
291
+ const result = await client.delete(`/v1/media/${encodeURIComponent(media_id)}/permanent`);
292
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
293
+ }
294
+ );
295
+
296
+ // ─── move_media ────────────────────────────────────────────
297
+ server.tool(
298
+ 'move_media',
299
+ 'Move a media item to a different project. Caller must own the item AND have access to the target project. Items in shared projects from other members cannot be moved by you. Use this when the user says "move this to project X" or wants to reorganize.',
300
+ {
301
+ media_id: z.string().describe('MediaLibraryItem id to move.'),
302
+ project_id: z.string().describe('Target project id (use `app_builder_list_projects` to discover ids).')
303
+ },
304
+ async ({ media_id, project_id }) => {
305
+ const result = await client.patch(
306
+ `/v1/media/${encodeURIComponent(media_id)}/project`,
307
+ { project_id }
308
+ );
309
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
310
+ }
311
+ );
312
+
313
+ // ─── bulk_delete_media ─────────────────────────────────────
314
+ server.tool(
315
+ 'bulk_delete_media',
316
+ 'Soft-delete up to 1000 media items in one call. Items go to trash (30-day recovery). Owner only — items not owned by the user are silently skipped (count returned in response). Use this for "clean up all my old [type]" or "delete the failed generations from yesterday".',
317
+ {
318
+ media_ids: z.array(z.string()).describe('Array of MediaLibraryItem ids. Up to 1000 per call.')
319
+ },
320
+ async ({ media_ids }) => {
321
+ const result = await client.post('/v1/media/bulk/delete', { media_ids });
322
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
323
+ }
324
+ );
325
+
326
+ // ─── bulk_restore_media ────────────────────────────────────
327
+ server.tool(
328
+ 'bulk_restore_media',
329
+ 'Restore up to 1000 trashed media items at once. Owner only. Returns the count restored and how many ids weren\'t in trash (already active or not owned).',
330
+ {
331
+ media_ids: z.array(z.string()).describe('Array of trashed MediaLibraryItem ids to restore. Up to 1000.')
332
+ },
333
+ async ({ media_ids }) => {
334
+ const result = await client.post('/v1/media/bulk/restore', { media_ids });
335
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
336
+ }
337
+ );
338
+
339
+ // ─── bulk_permanently_delete_media ─────────────────────────
340
+ server.tool(
341
+ 'bulk_permanently_delete_media',
342
+ 'PERMANENTLY delete up to 1000 media items. NOT REVERSIBLE — removes from MongoDB, S3, folders, and source generation records. Owner only. ALWAYS confirm with the user before calling; this is the bulk equivalent of `permanently_delete_media`.',
343
+ {
344
+ media_ids: z.array(z.string()).describe('Array of MediaLibraryItem ids to permanently delete. Up to 1000. Cannot be undone.')
345
+ },
346
+ async ({ media_ids }) => {
347
+ const result = await client.post('/v1/media/bulk/permanent', { media_ids });
348
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
349
+ }
350
+ );
351
+
352
+ // ─── bulk_move_media ───────────────────────────────────────
353
+ server.tool(
354
+ 'bulk_move_media',
355
+ 'Move up to 1000 media items to a different project in a single call. Caller must own ALL items AND have access to the target project — if any item isn\'t owned by the caller, the entire operation is rejected (atomic).',
356
+ {
357
+ media_ids: z.array(z.string()).describe('Array of MediaLibraryItem ids to move. Up to 1000.'),
358
+ project_id: z.string().describe('Target project id.')
359
+ },
360
+ async ({ media_ids, project_id }) => {
361
+ const result = await client.post('/v1/media/bulk/move', { media_ids, project_id });
362
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
363
+ }
364
+ );
365
+
366
+ // ─── move_folder_contents ──────────────────────────────────
367
+ server.tool(
368
+ 'move_folder_contents',
369
+ 'Move every media item inside a folder to a different project. Caller must own ALL items in the folder AND have access to the target project. Shared folder members cannot use this — only the item owner can move items between projects.',
370
+ {
371
+ folder_id: z.string().describe('Folder id whose contents will be moved.'),
372
+ project_id: z.string().describe('Target project id.')
373
+ },
374
+ async ({ folder_id, project_id }) => {
375
+ const result = await client.post(
376
+ `/v1/media/folders/${encodeURIComponent(folder_id)}/move-contents`,
377
+ { project_id }
378
+ );
379
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
380
+ }
381
+ );
382
+
383
+ // ─── get_media_stats ───────────────────────────────────────
384
+ server.tool(
385
+ 'get_media_stats',
386
+ 'Get counts and total storage size of the user\'s media (or a specific project\'s media). Returns `{ total, images, videos, audio, total_size_bytes }`. Use this for "how many videos do I have", "what\'s my storage usage", or before bulk operations to estimate scope.',
387
+ {
388
+ project_id: z.string().optional().describe('Optional project id to scope stats to one project. Omit for the user\'s personal library across all projects.')
389
+ },
390
+ async ({ project_id }) => {
391
+ const params = new URLSearchParams();
392
+ if (project_id) params.set('project_id', project_id);
393
+ const qs = params.toString();
394
+ const result = await client.get(qs ? `/v1/media/stats?${qs}` : '/v1/media/stats');
395
+ return { content: [{ type: 'text', text: JSON.stringify(result.stats || result, null, 2) }] };
396
+ }
397
+ );
398
+
399
+ // ─── unshare_media_folder ──────────────────────────────────
400
+ server.tool(
401
+ 'unshare_media_folder',
402
+ 'Revoke a single user\'s access to a folder. Owner only. The user keeps any media they uploaded — only the folder access is removed.',
403
+ {
404
+ folder_id: z.string().describe('Folder id.'),
405
+ user_id: z.string().describe('User id to revoke (from the folder\'s `shared_with` array).')
406
+ },
407
+ async ({ folder_id, user_id }) => {
408
+ const result = await client.delete(
409
+ `/v1/media/folders/${encodeURIComponent(folder_id)}/share/${encodeURIComponent(user_id)}`
410
+ );
411
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
412
+ }
413
+ );
76
414
  }
77
415
 
78
416
  module.exports = { registerMediaTools };
@@ -9,14 +9,28 @@ function registerModelTools(server, client) {
9
9
  // ─── list_models ───────────────────────────────────────────
10
10
  server.tool(
11
11
  'list_models',
12
- 'List available AI models on Kolbo. Filter by type to find models for a specific generation type.',
12
+ 'List available AI models on Kolbo. Filter by `type` to narrow to a generation type, and pass `format: "json"` to get the raw model documents (every constraint field, useful for programmatic comparison / cap validation before submitting a generation). Default `format: "text"` returns the human-readable summary.',
13
13
  {
14
- type: z.string().optional().describe('Filter by DB type name: "text_to_img", "image_editing", "text_to_video", "img_to_video", "draw_to_video", "video_to_video", "elements", "firstlastgenerations", "lipsync-image", "lipsync-video", "music_gen", "text_to_speech", "text_to_sound", "stt", "text". Legacy aliases also accepted: "image", "image_edit", "video", "video_from_image", "video_from_video", "music", "speech", "sound", "chat", "lipsync" (both lipsync types), "three_d" (all 3D types), "first_last_frame", "transcription". Omit for all models.')
14
+ type: z.string().optional().describe('Filter by DB type name: "text_to_img", "image_editing", "text_to_video", "img_to_video", "draw_to_video", "video_to_video", "elements", "firstlastgenerations", "lipsync-image", "lipsync-video", "music_gen", "text_to_speech", "text_to_sound", "stt", "text". Legacy aliases also accepted: "image", "image_edit", "video", "video_from_image", "video_from_video", "music", "speech", "sound", "chat", "lipsync" (both lipsync types), "three_d" (all 3D types), "first_last_frame", "transcription". Omit for all models.'),
15
+ format: z.enum(['text', 'json']).optional().describe('Output format. "text" (default) returns a human-readable summary with the most-used caps. "json" returns the raw model documents from the API — use this when you need to programmatically verify caps (max_reference_images, max_visual_dna, max_video_duration, supported_aspect_ratios, etc.) before passing an array/value that might exceed a model-specific limit. The JSON form is the source of truth; the text form is a convenience preview.')
15
16
  },
16
- async ({ type }) => {
17
+ async ({ type, format }) => {
17
18
  const path = type ? `/v1/models?type=${encodeURIComponent(type)}` : '/v1/models';
18
19
  const result = await client.get(path);
19
20
 
21
+ // JSON mode — return the raw API documents unchanged. This is the
22
+ // authoritative shape; every constraint the agent might need to validate
23
+ // a request lives here (durations, reference caps, audio/video min/max,
24
+ // resolution multipliers, supports_* flags, prompt-length limits, etc.).
25
+ if (format === 'json') {
26
+ return {
27
+ content: [{
28
+ type: 'text',
29
+ text: JSON.stringify({ count: result.count, models: result.models }, null, 2)
30
+ }]
31
+ };
32
+ }
33
+
20
34
  // Split into auto-selectable (has summary) and named-only (no summary)
21
35
  const withSummary = result.models.filter(m => m.summary && m.summary.trim() !== '');
22
36
  const withoutSummary = result.models.filter(m => !m.summary || m.summary.trim() === '');
@@ -25,8 +39,26 @@ function registerModelTools(server, client) {
25
39
  // it has to guess `supported_resolutions`/`supported_durations` and
26
40
  // either invents values (then the API silently substitutes) or asks
27
41
  // the user to clarify what's only knowable from this list.
42
+ //
43
+ // Rendering rule: emit a line for EVERY known constraint that is
44
+ // applicable for this model's type — even when the value is 0 / null.
45
+ // Hiding "0 cap" lines used to mean the agent couldn't distinguish
46
+ // "this model rejects DNA" (cap = 0) from "I don't know" (field
47
+ // missing). Now an explicit `max_dna: 0 (DNA not supported)` says the
48
+ // model says no, and absence means the API doesn't expose the field.
28
49
  const formatSpecs = m => {
29
50
  const parts = [];
51
+ const types = Array.isArray(m.types) ? m.types : [];
52
+ const isVideoType = types.some(t =>
53
+ ['text_to_video', 'img_to_video', 'video_to_video', 'elements',
54
+ 'firstlastgenerations', 'lipsync-image', 'lipsync-video', 'draw_to_video'].includes(t)
55
+ );
56
+ const isElements = types.includes('elements');
57
+ const isV2V = types.includes('video_to_video');
58
+ const isLipsyncVideo = types.includes('lipsync-video');
59
+ const isLipsyncImage = types.includes('lipsync-image');
60
+ const isImageEdit = types.includes('image_editing');
61
+ const isImage = types.includes('text_to_img') || isImageEdit;
30
62
 
31
63
  if (Array.isArray(m.supported_resolutions) && m.supported_resolutions.length) {
32
64
  const mult = m.resolution_multipliers || {};
@@ -38,33 +70,70 @@ function registerModelTools(server, client) {
38
70
  );
39
71
  }
40
72
 
73
+ // Output durations (video gen output, not source video)
41
74
  if (Array.isArray(m.supported_durations) && m.supported_durations.length) {
42
75
  const ds = m.supported_durations;
43
- // Compact ranges like 4-15 if it's a contiguous run.
44
76
  const sorted = [...ds].sort((a, b) => a - b);
45
77
  const isRange = sorted.length > 2 && sorted.every((v, i) => i === 0 || v - sorted[i - 1] === 1);
46
78
  parts.push(`durations: ${isRange ? `${sorted[0]}-${sorted[sorted.length - 1]}s` : sorted.join('/') + 's'}`);
79
+ } else if (isVideoType && (m.min_output_duration != null || m.max_output_duration != null)) {
80
+ parts.push(`duration_range: ${m.min_output_duration ?? '?'}-${m.max_output_duration ?? '?'}s${m.default_duration != null ? ` (default ${m.default_duration}s)` : ''}`);
47
81
  }
48
82
 
49
- if (Array.isArray(m.supported_aspect_ratios) && m.supported_aspect_ratios.length) {
50
- parts.push(`aspect: ${m.supported_aspect_ratios.join(', ')}`);
83
+ // Aspect ratios prefer per-type override if set
84
+ const ratios = m.supported_aspect_ratios_by_type
85
+ ? Object.entries(m.supported_aspect_ratios_by_type).map(([t, arr]) => `${t}: ${arr.join('/')}`)
86
+ : null;
87
+ if (ratios) {
88
+ parts.push(`aspect (per-type): ${ratios.join(' | ')}`);
89
+ } else if (Array.isArray(m.supported_aspect_ratios) && m.supported_aspect_ratios.length) {
90
+ parts.push(`aspect: ${m.supported_aspect_ratios.join(', ')}${m.default_aspect_ratio ? ` (default ${m.default_aspect_ratio})` : ''}`);
91
+ }
92
+
93
+ // Reference-input caps — show the slot relevant for this model family.
94
+ // The same conceptual "max reference images" lives under THREE field
95
+ // names depending on the model type. Be explicit about which is which
96
+ // so the agent reads the right one.
97
+ if (isImage || isImageEdit) {
98
+ parts.push(`max_reference_images: ${m.max_reference_images ?? 0}${(m.max_reference_images ?? 0) === 0 ? ' (no refs)' : ''}`);
99
+ }
100
+ if (isElements) {
101
+ parts.push(`elements caps: imgs=${m.elements_max_images ?? 0} · vids=${m.elements_max_videos ?? 0} · audio=${m.elements_max_audio ?? 0}`);
102
+ }
103
+ if (isV2V) {
104
+ parts.push(`v2v ref caps: imgs=${m.max_images ?? 0} · vids=${m.max_videos ?? 0} · elements=${m.max_elements ?? 0} · audio=${m.max_audio ?? 0}`);
51
105
  }
52
106
 
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`);
107
+ // Visual DNA cap — always show for image / elements / video, even if 0.
108
+ // Use the authoritative supports_visual_dna flag when available; fall
109
+ // back to inferring from cap > 0 for older API responses.
110
+ const dnaSupported = typeof m.supports_visual_dna === 'boolean'
111
+ ? m.supports_visual_dna
112
+ : (m.max_visual_dna ?? 0) > 0;
113
+ if (isImage || isVideoType) {
114
+ const cap = m.max_visual_dna;
115
+ if (dnaSupported && cap != null && cap > 0) parts.push(`max_visual_dna: ${cap}`);
116
+ else if (dnaSupported && cap == null) parts.push('visual_dna: supported (no cap published — confirm before passing >3)');
117
+ else parts.push('visual_dna: not supported');
57
118
  }
58
119
 
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`);
120
+ // Source-video duration constraints — only matter for tools that take
121
+ // an INPUT video (lipsync-video, video_to_video).
122
+ if (isLipsyncVideo || isV2V) {
123
+ if (m.min_video_duration != null || m.max_video_duration != null) {
124
+ parts.push(`source_video: ${m.min_video_duration ?? '?'}-${m.max_video_duration ?? '?'}s`);
125
+ }
63
126
  }
64
127
 
65
- if ((m.max_visual_dna ?? 0) > 0) parts.push(`max_dna: ${m.max_visual_dna}`);
128
+ // Audio input lipsync, elements, music-driven flows.
129
+ if (m.max_audio_duration != null || m.min_audio_duration != null) {
130
+ parts.push(`audio_input: ${m.min_audio_duration ?? '?'}-${m.max_audio_duration ?? '?'}s${m.audio_max_follows_video_duration ? ' (max follows video)' : ''}`);
131
+ }
132
+ if (Array.isArray(m.supported_audio_formats) && m.supported_audio_formats.length) {
133
+ parts.push(`audio_formats: ${m.supported_audio_formats.join('/')}`);
134
+ }
66
135
 
67
- // Sound (only show when sound costs more or is generated natively)
136
+ // Native sound generation (video models that emit synced audio)
68
137
  if (m.sound_generation_type === 'native') {
69
138
  const mult = m.sound_credit_multiplier && m.sound_credit_multiplier !== 1
70
139
  ? ` (${m.sound_credit_multiplier}×)`
@@ -72,7 +141,22 @@ function registerModelTools(server, client) {
72
141
  parts.push(`sound: native${mult}${m.sound_enabled_by_default ? ' on-by-default' : ''}`);
73
142
  }
74
143
 
75
- if (m.max_audio_duration != null) parts.push(`audio_max: ${m.max_audio_duration}s`);
144
+ // Prompt constraints
145
+ if (m.requires_prompt === false) parts.push('prompt: optional');
146
+ if (m.min_prompt_length != null || m.max_prompt_length != null) {
147
+ parts.push(`prompt_length: ${m.min_prompt_length ?? 0}-${m.max_prompt_length ?? '∞'} chars`);
148
+ }
149
+
150
+ // Upload cap (when present)
151
+ if (m.max_file_size != null) {
152
+ const mb = Math.round(m.max_file_size / (1024 * 1024));
153
+ parts.push(`max_file_size: ${mb}MB`);
154
+ }
155
+
156
+ // Images-per-request (Midjourney-style fixed-N output)
157
+ if (m.images_per_request != null && m.images_per_request !== 1) {
158
+ parts.push(`images_per_request: ${m.images_per_request}`);
159
+ }
76
160
 
77
161
  return parts.length ? `\n ${parts.join(' | ')}` : '';
78
162
  };
@@ -91,7 +175,7 @@ function registerModelTools(server, client) {
91
175
  return {
92
176
  content: [{
93
177
  type: 'text',
94
- text: `Available models (${result.count}):\n\n${sections.join('\n\n')}\n\nUse the "identifier" value as the "model" parameter in generate tools.`
178
+ text: `Available models (${result.count}):\n\n${sections.join('\n\n')}\n\nUse the "identifier" value as the "model" parameter in generate tools. For programmatic cap validation, re-call with format: "json".`
95
179
  }]
96
180
  };
97
181
  }
@@ -113,6 +197,38 @@ function registerModelTools(server, client) {
113
197
  };
114
198
  }
115
199
  );
200
+
201
+ // ─── get_session_usage ─────────────────────────────────────
202
+ // Real, multiplier-adjusted credit spend tagged with the caller's
203
+ // X-Kolbo-Caller-Session-Id (set automatically by the parent process —
204
+ // no need to pass it). Use this to give the user an honest "you've spent
205
+ // X credits in this app session" instead of estimating from base credits.
206
+ server.tool(
207
+ 'get_session_usage',
208
+ '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.',
209
+ {},
210
+ async () => {
211
+ try {
212
+ const r = await client.get('/credit-usage/by-caller-session');
213
+ // The endpoint returns { message, data: { total, count, by_tool, by_model, recent[] } }
214
+ return {
215
+ content: [{
216
+ type: 'text',
217
+ text: JSON.stringify(r.data || r, null, 2)
218
+ }]
219
+ };
220
+ } catch (err) {
221
+ // 400 from the endpoint means no caller-session-id was forwarded —
222
+ // surface a clear hint instead of a generic API error.
223
+ const hint = err?.status === 400
224
+ ? '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.'
225
+ : err?.message || 'Failed to fetch session usage';
226
+ return {
227
+ content: [{ type: 'text', text: JSON.stringify({ error: hint }, null, 2) }]
228
+ };
229
+ }
230
+ }
231
+ );
116
232
  }
117
233
 
118
234
  module.exports = { registerModelTools };