@kolbo/mcp 1.54.0 → 1.55.1

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.
@@ -5,16 +5,70 @@
5
5
 
6
6
  const { z } = require('zod');
7
7
  const FormData = require('form-data');
8
- const { resolveToBuffer } = require('./_shared');
8
+ const { resolveToBuffer, DEFAULT_MAX_FILE_MB, compactList } = require('./_shared');
9
9
  const { UI, uiResult, appsEnabled } = require('../apps');
10
10
 
11
+ // How many tiles the media grid renders. A rendering limit only — the text
12
+ // payload always carries the full page, and `total` reports the real library
13
+ // count, so a capped grid can never be mistaken for "that's everything".
14
+ const GRID_CAP = 24;
15
+
16
+ async function mintUploadTicket(client) {
17
+ const ticket = await client.post('/v1/media/upload-ticket', {});
18
+ if (!ticket || !ticket.token) throw new Error('Could not create an upload ticket — try again.');
19
+ return ticket;
20
+ }
21
+
22
+ // Shape a /v1/media/upload-ticket response for a TEXT consumer (an agent that
23
+ // will POST the file itself). The widget path needs different keys (`expires_at`
24
+ // as an absolute ms timestamp for the countdown), so it builds its own payload.
25
+ //
26
+ // This carries the full recipe, not a pointer to it: both callers are agents
27
+ // holding a token they must use immediately, and telling one of them to go call
28
+ // another tool to learn the POST shape would spend the round trip this whole
29
+ // path exists to remove.
30
+ function uploadTicketPayload(ticket) {
31
+ return {
32
+ upload_url: ticket.upload_url,
33
+ token: ticket.token,
34
+ expires_in_seconds: ticket.expires_in,
35
+ max_file_mb: ticket.max_file_mb || DEFAULT_MAX_FILE_MB,
36
+ accepted: ticket.accepted,
37
+ how_to_upload: {
38
+ example: 'curl -X POST "<upload_url>" -H "Authorization: Bearer <token>" -F "file=@/absolute/path/to/file.mp3"',
39
+ optional_fields: ['project_id', 'description'],
40
+ response: 'JSON — the stable CDN URL is at media.url. One POST per file; reuse the ticket for a batch.',
41
+ // Git Bash hands curl a POSIX-style /c/Users/... path that Windows curl
42
+ // cannot open (exit 26). Real trap — it cost a round trip to find.
43
+ windows_note: 'Give curl a native path (C:/Users/...) — a Git Bash /c/Users/... path fails to open.',
44
+ },
45
+ // The one thing the server cannot detect: a caller with no shell that got
46
+ // here anyway. Without this it is left holding a token it can never use.
47
+ if_you_cannot_run_shell_or_http: 'Discard this ticket and call `media_upload_widget` instead so the user can pick the file.',
48
+ };
49
+ }
50
+
11
51
  function registerMediaTools(server, client, options = {}) {
12
52
  const ui = () => appsEnabled(server, options);
13
53
 
54
+ // `opts.apps` is set only by kolbo-api's per-request server (see createServer
55
+ // in ../index.js), which makes it a TRANSPORT signal — deliberately not
56
+ // `appsEnabled()`, which also returns true for stdio hosts that advertise UI.
57
+ // Transport is what decides whether a local path can resolve at all, so state
58
+ // it in the descriptions rather than making the model infer it. What the
59
+ // server still cannot know is whether the CALLER has a shell (one connector
60
+ // serves both claude.ai and Claude Code), hence the fallback hint in the
61
+ // ticket payload.
62
+ const isRemoteConnector = options.apps === true;
63
+
64
+ const ticketRouting = isRemoteConnector
65
+ ? 'You are reached over a REMOTE connector: this server cannot read the caller\'s disk, so `upload_media` with a local path will always fail here — do not try it. If you can run shell commands or issue HTTP requests yourself, this tool is the right path. If you cannot (claude.ai web/mobile), ignore this tool and call `media_upload_widget` so the user picks the file.'
66
+ : 'You are a LOCAL (stdio) install: server and client share a filesystem, so for an ordinary local file prefer `upload_media` with the absolute path — one call, no ticket needed. Use this tool only when you specifically want to stream files up yourself (large batches, CI, an external uploader).';
67
+
14
68
  // ─── media_upload_widget ───────────────────────────────────
15
69
  server.tool(
16
70
  'media_upload_widget',
17
- 'Open an interactive file-upload card in the chat so the user can upload LOCAL files (images, videos, audio, documents) into their Kolbo media library. USE THIS IMMEDIATELY whenever a claude.ai (browser/mobile) user wants to use a local file, or references a file they attached to the chat — remote MCP tools CANNOT read chat attachments, so the user must re-upload through this widget; do not ask them to re-attach the file in chat. Each uploaded file gets a stable Kolbo CDN URL that arrives in a follow-up user message — then pass those URLs to generation tools (generate_image_edit, generate_video_from_image, generate_lipsync, transcribe_audio, visual DNA, etc.). On Claude Desktop / Claude Code with filesystem access, prefer `upload_media` with the local path instead.',
71
+ 'Open an interactive file-upload card in the chat so the user can upload LOCAL files (images, videos, audio, documents) into their Kolbo media library. USE THIS IMMEDIATELY whenever a claude.ai (browser/mobile) user wants to use a local file, or references a file they attached to the chat — remote MCP tools CANNOT read chat attachments, so the user must re-upload through this widget; do not ask them to re-attach the file in chat. Each uploaded file gets a stable Kolbo CDN URL that arrives in a follow-up user message — then pass those URLs to generation tools (generate_image_edit, generate_video_from_image, generate_lipsync, transcribe_audio, visual DNA, etc.). ROUTING depends on where the SERVER runs, not on which client you are: `upload_media` with a local path only works on a LOCAL (stdio) install, where server and client share a filesystem. Over a remote connector a local path is unreachable however capable the client is — there, if you can run shell commands, call `create_upload_ticket` and POST the file yourself (no user interaction needed); use this widget when you cannot reach the filesystem (claude.ai web/mobile) or when the user should choose the file.',
18
72
  {
19
73
  purpose: z.string().optional().describe('Short title shown on the card, e.g. "Upload the photo to animate". Helps the user know what to drop.'),
20
74
  media_types: z.array(z.enum(['image', 'video', 'audio', 'document'])).optional().describe('Restrict which file kinds the widget accepts. Omit to accept all types.'),
@@ -22,8 +76,7 @@ function registerMediaTools(server, client, options = {}) {
22
76
  project_id: z.string().optional().describe('Project ObjectId to file the uploads into (resolve names via `list_projects`).')
23
77
  },
24
78
  async ({ purpose, media_types, max_files, project_id }) => {
25
- const ticket = await client.post('/v1/media/upload-ticket', {});
26
- if (!ticket || !ticket.token) throw new Error('Could not create an upload ticket — try again.');
79
+ const ticket = await mintUploadTicket(client);
27
80
 
28
81
  const info = {
29
82
  status: 'upload_widget_opened',
@@ -41,18 +94,41 @@ function registerMediaTools(server, client, options = {}) {
41
94
  expires_at: Date.now() + (ticket.expires_in || 900) * 1000,
42
95
  kinds: media_types && media_types.length ? media_types : undefined,
43
96
  max_files: Math.min(Math.max(Number(max_files) || 10, 1), 20),
44
- max_mb: ticket.max_file_mb || { image: 50, video: 500, audio: 200, document: 50 },
97
+ max_mb: ticket.max_file_mb || DEFAULT_MAX_FILE_MB,
45
98
  ...(project_id ? { project_id } : {}),
46
99
  });
47
100
  }
48
101
 
49
- // Text-only host: no iframe to render — steer to upload_media.
102
+ // Text-only host (Claude Code, Codex CLI, Cursor): no iframe to render —
103
+ // but these are exactly the hosts that CAN reach a filesystem, so hand
104
+ // back the ticket already minted instead of dead-ending. Additive fields
105
+ // only; `status` is unchanged for any existing consumer.
50
106
  return {
51
107
  content: [{
52
108
  type: 'text',
53
109
  text: JSON.stringify({
54
110
  status: 'widget_unavailable',
55
- hint: 'This host cannot render the upload widget. Use the upload_media tool with a URL or absolute local file path instead.'
111
+ hint: 'This host cannot render the upload card, but it can usually reach the filesystem. Upload the file yourself by POSTing it to upload_url with this ticket — the recipe is below. On a LOCAL stdio install you can also just call upload_media with the absolute path.',
112
+ ...uploadTicketPayload(ticket),
113
+ }, null, 2)
114
+ }]
115
+ };
116
+ }
117
+ );
118
+
119
+ // ─── create_upload_ticket ──────────────────────────────────
120
+ server.tool(
121
+ 'create_upload_ticket',
122
+ 'Get a short-lived ticket for uploading LOCAL files straight into the user\'s Kolbo media library, with NO upload card and no user interaction. ' + ticketRouting + ' Why it exists: when the server cannot read the caller\'s disk, the only other ways in are making the user click an upload card (`media_upload_widget`) or inlining the file as base64 via `upload_media` — base64 is slow and burns context in proportion to file size, so do not use it for anything but a tiny file. Returns `upload_url` + `token`; POST each file as multipart field `file` with header `Authorization: Bearer <token>` and read the CDN URL from `media.url` in the response. One POST per file; the ticket is reusable until it expires. Then pass those URLs to any generation tool (transcribe_audio, generate_image_edit, generate_video_from_image, generate_lipsync, visual DNA, …).',
123
+ {},
124
+ async () => {
125
+ const ticket = await mintUploadTicket(client);
126
+ return {
127
+ content: [{
128
+ type: 'text',
129
+ text: JSON.stringify({
130
+ status: 'upload_ticket_created',
131
+ ...uploadTicketPayload(ticket),
56
132
  }, null, 2)
57
133
  }]
58
134
  };
@@ -128,7 +204,7 @@ function registerMediaTools(server, client, options = {}) {
128
204
  'list_media',
129
205
  '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.',
130
206
  {
131
- project_id: z.string().optional().describe('Restrict to a single project (Mongo ObjectId). Use `list_projects` to discover IDs (NOT `app_builder_list_projects` — that is App Builder only). Omit to list across all the user\'s media.'),
207
+ project_id: z.string().optional().describe('Restrict to a single project (Mongo ObjectId). Use `list_projects` to discover IDs. Omit to list across all the user\'s media.'),
132
208
  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.'),
133
209
  type: z.enum(['image', 'video', 'audio', 'all']).optional().describe('Filter by media type. Default: all types.'),
134
210
  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.'),
@@ -155,10 +231,25 @@ function registerMediaTools(server, client, options = {}) {
155
231
 
156
232
  const media = result.media || [];
157
233
  const pagination = result.pagination || null;
158
- const text = JSON.stringify({ media, pagination }, null, 2);
234
+ // A default page of 50 items measured 119,847 chars — every row carries a
235
+ // full metadata object and the original prompt. Keep what identifies and
236
+ // locates an item; get_media returns one in full.
237
+ const text = compactList(media, {
238
+ fields: ['id', 'filename', 'media_type', 'url', 'thumbnail_url', 'size', 'project_id', 'created_at'],
239
+ cap: 50,
240
+ total: pagination ? (pagination.total_items != null ? pagination.total_items : pagination.total) : media.length,
241
+ extra: pagination ? { pagination } : undefined,
242
+ note: 'Narrow with `type`, `category`, `project_id`, `folder_id`, or `search`; get_media returns one item in full.',
243
+ });
159
244
 
160
245
  if (ui()) {
161
- const items = media.slice(0, 24).map((m) => ({
246
+ // The SDK envelope reports `total_items` (see sdk/controller.js listMedia);
247
+ // reading `total` always came back undefined, so the grid claimed the page
248
+ // size was the whole library. Accept either, then fall back.
249
+ const totalItems = pagination
250
+ ? (pagination.total_items != null ? pagination.total_items : pagination.total)
251
+ : null;
252
+ const items = media.slice(0, GRID_CAP).map((m) => ({
162
253
  id: m.id,
163
254
  title: m.filename,
164
255
  subtitle: m.media_type + (m.size ? ' · ' + Math.round(m.size / 1024) + 'KB' : ''),
@@ -171,7 +262,8 @@ function registerMediaTools(server, client, options = {}) {
171
262
  widget: 'media-grid',
172
263
  title: 'Media Library',
173
264
  items,
174
- total: pagination && pagination.total != null ? pagination.total : media.length
265
+ total: totalItems != null ? totalItems : media.length,
266
+ shown: Math.min(media.length, GRID_CAP)
175
267
  });
176
268
  }
177
269
 
@@ -409,7 +501,7 @@ function registerMediaTools(server, client, options = {}) {
409
501
  '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.',
410
502
  {
411
503
  media_id: z.string().describe('MediaLibraryItem id to move.'),
412
- project_id: z.string().describe('Target project id (use `list_projects` to discover ids — NOT `app_builder_list_projects`).')
504
+ project_id: z.string().describe('Target project id (use `list_projects` to discover ids).')
413
505
  },
414
506
  async ({ media_id, project_id }) => {
415
507
  const result = await client.patch(
@@ -271,10 +271,46 @@ function registerModelTools(server, client, options = {}) {
271
271
  return parts.length ? `\n ${parts.join(' | ')}` : '';
272
272
  };
273
273
 
274
+ // The FULL catalog with every spec line measured 140,590 chars — past what
275
+ // hosts accept, on the one discovery tool the skill tells the model to call
276
+ // when it is unsure. Unfiltered, emit the one-line form (enough to choose a
277
+ // model); once `type` narrows it, the set is small enough for full specs.
278
+ const detailed = !!type;
279
+ // Summaries run to a paragraph each; across the whole catalog that alone
280
+ // is most of the payload. Unfiltered, one clause is enough to choose by.
281
+ const brief = (s) => {
282
+ if (!s) return '';
283
+ const flat = String(s).replace(/\s+/g, ' ').trim();
284
+ return flat.length > 130 ? flat.slice(0, 127).trimEnd() + '…' : flat;
285
+ };
274
286
  const formatModel = m =>
275
- `${m.identifier} (${m.name}) - ${m.credit} credits${m.recommended ? ' [RECOMMENDED]' : ''}${m.new_model ? ' [NEW]' : ''}${m.summary ? ` — ${m.summary}` : ''}${formatSpecs(m)}`;
287
+ `${m.identifier} (${m.name}) - ${m.credit} credits${m.recommended ? ' [RECOMMENDED]' : ''}${m.new_model ? ' [NEW]' : ''}${m.summary ? ` — ${detailed ? m.summary : brief(m.summary)}` : ''}${detailed ? formatSpecs(m) : ''}`;
276
288
 
277
289
  const sections = [];
290
+
291
+ if (!detailed) {
292
+ // The catalog is ~428 models. Listing all of them is both far past the
293
+ // text budget AND useless to choose from — so unfiltered, surface the
294
+ // curated picks and make the model narrow by `type` for the rest. This
295
+ // matches the connector rule of steering to a CONCRETE model.
296
+ const picks = result.models.filter(m => m.recommended || m.new_model);
297
+ if (picks.length) {
298
+ sections.push(`Recommended & new (${picks.length}):\n${picks.map(formatModel).join('\n')}`);
299
+ }
300
+ const text = `Kolbo model catalog — ${result.count} models total.\n\n`
301
+ + `${sections.join('\n\n')}\n\n`
302
+ + 'This is the curated shortlist, not the full catalog. To see everything in a '
303
+ + 'category (with per-model resolutions, durations, aspect ratios and reference-image '
304
+ + 'caps), re-call with `type`:\n'
305
+ + ' text_to_img · image_editing · text_to_video · img_to_video · video_to_video ·\n'
306
+ + ' first_last_frame · elements · lipsync · music_gen · text_to_speech ·\n'
307
+ + ' text_to_sound · stt · three_d · text\n\n'
308
+ + 'Use the "identifier" value as the "model" parameter in generate tools. '
309
+ + 'For raw documents (programmatic cap validation), re-call with format: "json".';
310
+ if (ui()) return uiResult(UI.catalog, text, buildCatalogStructured(result.models, type, !showCatalog));
311
+ return { content: [{ type: 'text', text }] };
312
+ }
313
+
278
314
  if (withSummary.length > 0) {
279
315
  sections.push(`Auto-selectable models (${withSummary.length}) — safe to pick based on quality + cost:\n${withSummary.map(formatModel).join('\n')}`);
280
316
  }
@@ -282,7 +318,7 @@ function registerModelTools(server, client, options = {}) {
282
318
  sections.push(`Named-only models (${withoutSummary.length}) — only use if the user explicitly requests by name:\n${withoutSummary.map(formatModel).join('\n')}`);
283
319
  }
284
320
 
285
- const 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".`;
321
+ const text = `Available ${type} 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".`;
286
322
  if (ui()) return uiResult(UI.catalog, text, buildCatalogStructured(result.models, type, !showCatalog));
287
323
  return { content: [{ type: 'text', text }] };
288
324
  }
@@ -312,9 +348,25 @@ function registerModelTools(server, client, options = {}) {
312
348
  // X credits in this app session" instead of estimating from base credits.
313
349
  server.tool(
314
350
  'get_session_usage',
315
- '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.',
351
+ '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. ONLY works when running under Kolbo Code — on the claude.ai connector and other hosts there is no per-app session to scope to; use check_credits there instead.',
316
352
  {},
317
353
  async () => {
354
+ // Session scoping needs KOLBO_CALLER_SESSION_ID, which only the Kolbo Code
355
+ // parent process sets. The remote connector serves every user from one
356
+ // process, so it is never present there — calling anyway just returns a 400
357
+ // telling the user to reconfigure a process they do not control.
358
+ if (!process.env.KOLBO_CALLER_SESSION_ID) {
359
+ return {
360
+ content: [{
361
+ type: 'text',
362
+ text: JSON.stringify({
363
+ unavailable: 'Per-session usage is only tracked when running under Kolbo Code.',
364
+ reason: 'This host does not scope tool calls to an app session, so there is no session to total.',
365
+ use_instead: 'check_credits for the current balance, or the Usage page at https://app.kolbo.ai.'
366
+ })
367
+ }]
368
+ };
369
+ }
318
370
  try {
319
371
  const r = await client.get('/credit-usage/by-caller-session');
320
372
  // The endpoint returns { message, data: { total, count, by_tool, by_model, recent[] } }
@@ -75,7 +75,7 @@ function registerMoodboardTools(server, client, options = {}) {
75
75
  'Create a moodboard from 1–15 image URLs. The server analyzes the images and synthesizes a reusable master style prompt — then pass the returned moodboard id as `moodboard_id` on generation tools to apply the style. Use Kolbo URLs (generated images or `upload_media` output) or any public image URL. Typical flow: generate/upload reference images → create_moodboard → generate with moodboard_id.',
76
76
  {
77
77
  name: z.string().describe('Moodboard name (1–100 chars).'),
78
- image_urls: z.array(z.string()).min(1).max(15).describe('1–15 public image URLs. For local files, call upload_media first and use the returned URLs.'),
78
+ image_urls: z.array(z.string()).min(1).max(15).describe('1–15 public image URLs. For local files, get URLs first via the LOCAL FILE route in this tool\'s description.'),
79
79
  style_guide: z.string().optional().describe('Optional style notes (max 500 chars) that steer the analysis, e.g. "focus on the color grading, not the subjects".')
80
80
  },
81
81
  async ({ name, image_urls, style_guide }) => {
@@ -124,11 +124,38 @@ function registerMusicLibraryTools(server, client, options = {}) {
124
124
 
125
125
  server.tool(
126
126
  'get_music_library_facets',
127
- 'List SYNCI genres, moods, instruments, BPM, and duration filters.',
128
- {},
129
- async () => {
127
+ 'List SYNCI genres, moods, instruments, BPM, and duration filters. Returns the most-used values ' +
128
+ 'per facet (ranked by track count); raise `limit` if you need deeper coverage. Any value not ' +
129
+ 'listed still works as a free-text `query` on search_music_library.',
130
+ {
131
+ limit: z.number().optional().describe(
132
+ 'How many values to return per facet, ranked by track count. Default: 40. Max: 200.'
133
+ ),
134
+ },
135
+ async ({ limit }) => {
130
136
  const result = await client.get('/v1/music-library/facets');
131
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
137
+
138
+ // The raw response carries 169 genres and 1000 each of moods/instruments — ~128K
139
+ // chars, which exceeds what hosts accept, so this tool used to fail outright. The
140
+ // tail is single-digit-count noise; the head is what anyone actually filters on.
141
+ const cap = Math.min(Math.max(Number(limit) || 40, 1), 200);
142
+ const trim = (arr) => (Array.isArray(arr) ? arr.slice(0, cap) : arr);
143
+ const omitted = (arr) => (Array.isArray(arr) ? Math.max(arr.length - cap, 0) : 0);
144
+
145
+ const payload = {
146
+ ...result,
147
+ genres: trim(result.genres),
148
+ moods: trim(result.moods),
149
+ instruments: trim(result.instruments),
150
+ _truncated: {
151
+ genres: omitted(result.genres),
152
+ moods: omitted(result.moods),
153
+ instruments: omitted(result.instruments),
154
+ hint: 'Values beyond these are long-tail. Pass a higher `limit`, or just use `query` on ' +
155
+ 'search_music_library — free text matches values not listed here.',
156
+ },
157
+ };
158
+ return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
132
159
  },
133
160
  );
134
161
 
@@ -5,6 +5,7 @@
5
5
 
6
6
  const { z } = require('zod');
7
7
  const { UI, uiResult, appsEnabled } = require('../apps');
8
+ const { compactList } = require('./_shared');
8
9
 
9
10
  function registerPresetTools(server, client, options = {}) {
10
11
  const ui = () => appsEnabled(server, options);
@@ -22,11 +23,15 @@ function registerPresetTools(server, client, options = {}) {
22
23
  const result = await client.get(`/v1/presets${qs ? '?' + qs : ''}`);
23
24
 
24
25
  const presets = result.presets || [];
25
- const text = JSON.stringify({
26
- presets,
27
- count: result.count || 0,
28
- ...(result.warning ? { warning: result.warning } : {})
29
- }, null, 2);
26
+ // Full catalog measured 632,919 chars — the single biggest payload in the
27
+ // tool surface. The model only needs enough to pick a preset_id.
28
+ const text = compactList(presets, {
29
+ fields: ['id', 'name', 'category', 'type', 'description'],
30
+ cap: 60,
31
+ total: result.count || presets.length,
32
+ extra: result.warning ? { warning: result.warning } : undefined,
33
+ note: 'Filter with `type` (image | video | music | text_to_video) to see a focused set.',
34
+ });
30
35
 
31
36
  if (ui()) {
32
37
  return uiResult(UI.mediaGrid, text, {
@@ -64,22 +69,56 @@ function registerPresetTools(server, client, options = {}) {
64
69
  'specific cinematic look; then pass the chosen ids via the `cinematic` arg of generate_image / ' +
65
70
  'generate_image_edit — at most one id per dimension. "Auto" is the absence of a selection: omit a ' +
66
71
  'dimension (or the whole `cinematic` object) to let the enhancer decide. For an ordinary generation ' +
67
- 'do not call this at all. Never hardcode ids — dimensions and presets change; always fetch here.',
68
- {},
69
- async () => {
72
+ 'do not call this at all. Never hardcode ids — dimensions and presets change; always fetch here. ' +
73
+ 'Call with no args for a compact id+name index of every dimension, then pass `dimension` to get the ' +
74
+ 'full descriptions for just the one you are choosing from.',
75
+ {
76
+ dimension: z.string().optional().describe(
77
+ 'Return full detail (incl. descriptions) for ONE dimension only — e.g. "lighting", "camera", ' +
78
+ '"looks". Omit for the compact index of all dimensions.'
79
+ ),
80
+ },
81
+ async ({ dimension }) => {
70
82
  const result = await client.get('/v1/cinematic-presets');
71
83
  // The public route serves the raw grouped map ({ camera:[...], lens:[...] });
72
84
  // the SDK envelope wraps it as { dimensions:{...} }. Accept either shape.
73
85
  const dimensions = (result && result.dimensions) || result || {};
74
- const text = JSON.stringify({
75
- dimensions,
76
- available_dimensions: Object.keys(dimensions),
77
- _usage_hint: 'Include ONLY the dimensions the user actually wants; pass their ids as the ' +
78
- '`cinematic` arg on generate_image / generate_image_edit, e.g. {"camera":"<id>","lighting":"<id>"}. ' +
79
- 'Every omitted/null dimension is Auto the enhancer completes the look in the spirit of the ones ' +
80
- 'you set. Omit the whole object for a non-cinematic generation. Ids are validated per-dimension server-side.'
81
- }, null, 2);
82
- return { content: [{ type: 'text', text }] };
86
+ const names = Object.keys(dimensions);
87
+
88
+ // The full catalog pretty-printed is ~63K chars — past what hosts accept, so the
89
+ // tool used to fail outright. thumbnail_url is dead weight on a text surface, and
90
+ // descriptions are only needed for the dimension actually being chosen from.
91
+ const slim = (p) => ({ id: p.id, name: p.name });
92
+ const full = (p) => ({
93
+ id: p.id,
94
+ name: p.name,
95
+ description: p.description,
96
+ ...(p.bundle ? { bundle: p.bundle } : {}),
97
+ });
98
+
99
+ let payload;
100
+ if (dimension && dimensions[dimension]) {
101
+ payload = {
102
+ dimension,
103
+ presets: (dimensions[dimension] || []).map(full),
104
+ available_dimensions: names,
105
+ };
106
+ } else {
107
+ if (dimension) payload = { _note: `Unknown dimension "${dimension}" — showing the index.` };
108
+ payload = {
109
+ ...payload,
110
+ dimensions: Object.fromEntries(names.map((k) => [k, (dimensions[k] || []).map(slim)])),
111
+ available_dimensions: names,
112
+ _detail_hint: 'Names only. Call again with `dimension: "<name>"` for descriptions.',
113
+ };
114
+ }
115
+
116
+ payload._usage_hint = 'Include ONLY the dimensions the user actually wants; pass their ids as the ' +
117
+ '`cinematic` arg on generate_image / generate_image_edit, e.g. {"camera":"<id>","lighting":"<id>"}. ' +
118
+ 'Every omitted/null dimension is Auto — the enhancer completes the look in the spirit of the ones ' +
119
+ 'you set. Omit the whole object for a non-cinematic generation. Ids are validated per-dimension server-side.';
120
+
121
+ return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
83
122
  }
84
123
  );
85
124
  }
@@ -12,7 +12,7 @@ function registerProjectTools(server, client, options = {}) {
12
12
  // ─── list_projects ─────────────────────────────────────────
13
13
  server.tool(
14
14
  'list_projects',
15
- 'List the user\'s platform projects (owned + shared with edit/full/owner permission). Use this to resolve a project NAME the user mentioned ("put this in my Acme Campaign project") into the project ObjectId you pass back as `project_id` on generation / chat / upload / move tools. Whenever the user mentions a project by name OR location, you MUST call this first — those tools accept only ObjectIds, not names — and then pass the resolved `project_id` on EVERY subsequent call in the conversation (it is per-call, not sticky; omitting it drops work into the default bucket). Returns id, name, role, and is_default. The project flagged `is_default: true` is the auto-created "API Generations" bucket every SDK generation lands in when project_id is omitted. NOT the same as `app_builder_list_projects`, which scopes App Builder coding sessions only.',
15
+ 'List the user\'s platform projects (owned + shared with edit/full/owner permission). Use this to resolve a project NAME the user mentioned ("put this in my Acme Campaign project") into the project ObjectId you pass back as `project_id` on generation / chat / upload / move tools. Whenever the user mentions a project by name OR location, you MUST call this first — those tools accept only ObjectIds, not names — and then pass the resolved `project_id` on EVERY subsequent call in the conversation (it is per-call, not sticky; omitting it drops work into the default bucket). Returns id, name, role, and is_default. The project flagged `is_default: true` is the auto-created "API Generations" bucket every SDK generation lands in when project_id is omitted.',
16
16
  {},
17
17
  async () => {
18
18
  const result = await client.get('/v1/projects');
@@ -3,6 +3,7 @@
3
3
 
4
4
  const { z } = require('zod');
5
5
  const { UI, uiResult, appsEnabled } = require('../apps');
6
+ const { compactList } = require('./_shared');
6
7
 
7
8
  // Compact one-line render of a normalized stock asset.
8
9
  function assetLine(a) {
@@ -62,7 +63,15 @@ function registerStockLibraryTools(server, client, options = {}) {
62
63
  const assets = result.assets || [];
63
64
  if (!assets.length) return { content: [{ type: 'text', text: 'No assets found. Try a broader query, a different source/mediaType, or call get_stock_sources.' }] };
64
65
  const head = `Found ${assets.length} asset${assets.length === 1 ? '' : 's'}${result.total ? ` (≈${result.total} total)` : ''}${result.hasMore ? ' — more available (increment page)' : ''}:`;
65
- const text = `${head}\n\n${assets.map(assetLine).join('\n\n')}\n\nUse [source:sourceId] with get_stock_asset for full variants, or import_stock_asset to copy it into the media library.`;
66
+ // A wide `source=all` search interleaves providers and overran the text
67
+ // budget at ~27K. Cap the rendered rows; `page` still reaches the rest.
68
+ const ASSET_CAP = 30;
69
+ const shownAssets = assets.slice(0, ASSET_CAP);
70
+ const moreAssets = assets.length - shownAssets.length;
71
+ const moreHint = moreAssets > 0
72
+ ? `\n\n…and ${moreAssets} more on this page — narrow with \`source\` / \`mediaType\`, or use \`page\`.`
73
+ : '';
74
+ const text = `${head}\n\n${shownAssets.map(assetLine).join('\n\n')}${moreHint}\n\nUse [source:sourceId] with get_stock_asset for full variants, or import_stock_asset to copy it into the media library.`;
66
75
 
67
76
  if (ui()) {
68
77
  const items = assets.slice(0, 24).map((a) => {
@@ -113,7 +122,14 @@ function registerStockLibraryTools(server, client, options = {}) {
113
122
  async (args) => {
114
123
  const q = buildQuery(args);
115
124
  const result = await client.get(`/v1/stock/categories${q ? '?' + q : ''}`);
116
- return { content: [{ type: 'text', text: JSON.stringify({ count: result.count, categories: result.categories }, null, 2) }] };
125
+ // Full category list measured 257,817 chars (Kolbo SFX alone has 77 groups
126
+ // + 623 sub-filters). The model needs the label and the param to pass back.
127
+ return { content: [{ type: 'text', text: compactList(result.categories, {
128
+ fields: ['key', 'label', 'mediaType', 'source', 'group', 'paramType', 'providerParam'],
129
+ cap: 120,
130
+ total: result.count,
131
+ note: 'Narrow with `source` and `mediaType` to see a focused set.',
132
+ }) }] };
117
133
  }
118
134
  );
119
135
 
@@ -128,7 +144,11 @@ function registerStockLibraryTools(server, client, options = {}) {
128
144
  async (args) => {
129
145
  const q = buildQuery(args);
130
146
  const result = await client.get(`/v1/stock/collections${q ? '?' + q : ''}`);
131
- const text = JSON.stringify({ count: result.count, collections: result.collections }, null, 2);
147
+ const text = compactList(result.collections, {
148
+ fields: ['id', 'name', 'slug', 'mediaType', 'source', 'itemCount'],
149
+ cap: 60,
150
+ total: result.count,
151
+ });
132
152
 
133
153
  if (ui()) {
134
154
  const collections = result.collections || [];
@@ -5,7 +5,7 @@
5
5
 
6
6
  const { z } = require('zod');
7
7
  const FormData = require('form-data');
8
- const { resolveToBuffer: sharedResolveToBuffer, VISUAL_DNA_MAX_BYTES, projectScopeReadField } = require('./_shared');
8
+ const { resolveToBuffer: sharedResolveToBuffer, VISUAL_DNA_MAX_BYTES, projectScopeReadField, compactList } = require('./_shared');
9
9
  const { UI, uiResult, appsEnabled } = require('../apps');
10
10
 
11
11
  // Visual DNA caps reference media at 25MB per file (stricter than the
@@ -110,10 +110,14 @@ function registerVisualDnaTools(server, client, options = {}) {
110
110
  const qs = params.toString();
111
111
  const result = await client.get(`/v1/visual-dna${qs ? '?' + qs : ''}`);
112
112
  const dnas = result.visual_dnas || [];
113
- const text = JSON.stringify({
114
- visual_dnas: dnas,
115
- count: result.count || 0
116
- }, null, 2);
113
+ // Full profiles measured 74,310 chars — the embedded analysis/description
114
+ // blobs are large and the model only needs enough to pick an id.
115
+ const text = compactList(dnas, {
116
+ fields: ['id', 'name', 'type', 'folder_id', 'tags', 'thumbnail'],
117
+ cap: 60,
118
+ total: result.count || dnas.length,
119
+ note: 'Narrow with `search`, `tags`, or `collection`; get_visual_dna returns one in full.',
120
+ });
117
121
 
118
122
  if (ui()) {
119
123
  return uiResult(UI.mediaGrid, text, {
@@ -40,7 +40,16 @@ function registerVoiceTools(server, client, options = {}) {
40
40
  return `${v.voice_id} — ${v.name} (${v.provider})${v3}\n ${tags}${styles}${v.description ? `\n ${v.description}` : ''}`;
41
41
  });
42
42
 
43
- const text = `Available voices (${voices.length}):\n\n${lines.join('\n\n')}\n\nUse the "voice_id" value in generate_speech calls.`;
43
+ // The unfiltered catalog measured 190,286 chars past what hosts accept.
44
+ // Cap the listing and tell the model how to narrow, rather than handing
45
+ // back a blob the host truncates at an arbitrary byte.
46
+ const VOICE_CAP = 60;
47
+ const shown = lines.slice(0, VOICE_CAP);
48
+ const more = voices.length - shown.length;
49
+ const narrowHint = more > 0
50
+ ? `\n\n…and ${more} more. Filter by \`language\`, \`gender\`, or \`provider\` to narrow.`
51
+ : '';
52
+ const text = `Available voices (showing ${shown.length} of ${voices.length}):\n\n${shown.join('\n\n')}${narrowHint}\n\nUse the "voice_id" value in generate_speech calls.`;
44
53
 
45
54
  if (ui()) {
46
55
  return uiResult(UI.mediaGrid, text, {
@@ -1,85 +0,0 @@
1
- # Voice / Text-to-Speech (`generate_speech`) — full style & option reference
2
-
3
- `generate_speech` turns text into spoken audio. Every voice belongs to a
4
- **provider** (ElevenLabs, DeepDub, MiniMax, Google/Gemini, OpenAI, Zonos). Each
5
- provider exposes its own expressive controls. You may pass any control on any
6
- call — **the engine silently ignores controls that don't apply to the chosen
7
- voice's provider**, so you never need to branch on provider yourself.
8
-
9
- ## Pick the voice first
10
- Call `list_voices` (filter by `provider`, `language`, `gender`) and pass the
11
- returned `voice_id` — or a display name like `"Rachel"`. Cloned/custom voices
12
- resolve by name too. The voice determines the provider, which determines which
13
- controls below take effect.
14
-
15
- ## Core params (all providers)
16
- | Param | Type | Notes |
17
- |---|---|---|
18
- | `text` | string (required) | The words to speak. |
19
- | `voice` | string | Voice id or display name. Default `"Rachel"`. |
20
- | `model` | string | From `list_models type="text_to_speech"`. Default `eleven_v3`. Usually inferred from the voice — only needed to force a specific engine. |
21
- | `language` | string | BCP-47 code, e.g. `"en-US"`, `"he-IL"`, `"es-ES"`. |
22
- | `speaking_speed` | number | `0.5` (slow) – `2.0` (fast). Default `1.0`. Applies to ElevenLabs / OpenAI / Google. |
23
- | `project_id` | string | Scope into a project (see Projects rules). |
24
-
25
- ## Expressive style / emotion (provider-specific)
26
- | Param | Provider(s) | Values / notes |
27
- |---|---|---|
28
- | `style_instructions` | **Google / Gemini** | Free-form natural-language direction, e.g. `"whisper conspiratorially, slightly amused"`, `"excited sports announcer"`. Max 500 chars. |
29
- | `selected_style` | **DeepDub**, MiniMax | Preset style. DeepDub: `reading`, `conversational`, `angry`, `breathy`, `panic`, `amused`, `sad`, `whisper`, `singing`, `shout`, `scream`, `mumbling`, `excited`. |
30
- | `emotion` | **MiniMax** | `happy`, `sad`, `angry`, `fearful`, `disgusted`, `surprised`, `calm`, `fluent`, `whisper`. |
31
-
32
- ## ElevenLabs voice settings
33
- | Param | Range | Default | Effect |
34
- |---|---|---|---|
35
- | `similarity_boost` | 0–1 | 0.75 | Higher hews closer to the source voice. |
36
- | `style` | 0–1 | 0.5 | Style exaggeration — higher is more expressive/dramatic. |
37
- | `use_speaker_boost` | bool | true | Speaker-clarity boost. |
38
-
39
- ## DeepDub controls
40
- | Param | Range | Default | Effect |
41
- |---|---|---|---|
42
- | `variance` | 0–1 | 0.2 | More variation / takes. |
43
- | `tempo` | 0–2 | 1.0 | Pacing multiplier. |
44
- | `promptBoost` | bool | true | Higher fidelity to the text. |
45
- | `seed` | int | — | Reproducibility (same seed + inputs → same output). Also honored by Zonos. |
46
- | `accentControl` | object | — | `{ accentBaseLocale, accentLocale, accentRatio }` — blend an accent. Provide BOTH `accentBaseLocale` (e.g. `"en-US"`) and `accentLocale` (e.g. `"en-GB"`); `accentRatio` 0–1 (default 0.5). |
47
- | `voiceTitle` | string | — | Display title for a custom/cloned voice. |
48
-
49
- ## MiniMax fine controls
50
- | Param | Range | Default | Effect |
51
- |---|---|---|---|
52
- | `minimax_pitch` | −12 … 12 | 0 | Pitch shift. |
53
- | `minimax_vol` | 0–10 | 1 | Volume. |
54
- | `minimax_intensity` | — | — | Voice intensity. |
55
- | `minimax_timbre` | — | — | Voice timbre. |
56
-
57
- ## Examples
58
- Neutral ElevenLabs read:
59
- ```
60
- generate_speech(text="Welcome to Kolbo.", voice="Rachel")
61
- ```
62
- Whispered, conspiratorial Gemini delivery:
63
- ```
64
- generate_speech(text="Meet me at midnight.", voice="Kore",
65
- style_instructions="whisper conspiratorially, slow and breathy")
66
- ```
67
- Angry DeepDub take, faster:
68
- ```
69
- generate_speech(text="Get out of my house!", voice="<deepdub voice>",
70
- selected_style="angry", tempo=1.2)
71
- ```
72
- Excited MiniMax with pitch/volume tweaks:
73
- ```
74
- generate_speech(text="We won the championship!", voice="<minimax voice>",
75
- emotion="happy", minimax_pitch=3, minimax_vol=6)
76
- ```
77
- British-accented DeepDub blend:
78
- ```
79
- generate_speech(text="Good evening.", voice="<deepdub voice>",
80
- accentControl={ accentBaseLocale: "en-US", accentLocale: "en-GB", accentRatio: 0.7 })
81
- ```
82
-
83
- ## Credits
84
- ~5 credits per 100 characters for most TTS models (Zonos ~3; voice design/clone
85
- ~30 flat). Charged only on success. Use `check_credits` once per conversation.