@kolbo/mcp 1.60.0 → 1.62.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.60.0",
3
+ "version": "1.62.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/apps/index.js CHANGED
@@ -170,7 +170,7 @@ function uiResult(uri, text, structured) {
170
170
  /* ------------------------------------------------------------------ */
171
171
 
172
172
  const ICON_TTL_MS = 10 * 60 * 1000;
173
- const infoCache = new Map(); // apiBase → { at, byKey: Map<lowername, {icon, eta}> }
173
+ const infoCache = new Map(); // apiBase → { at, byKey: Map<lowername, info>, all: info[] }
174
174
 
175
175
  /**
176
176
  * Resolve a Model.avatar value to an absolute URL. Avatars are bare filenames
@@ -189,11 +189,12 @@ function resolveAvatarUrl(avatar) {
189
189
  return `${ICON_CDN_BASE}/${encodeURIComponent(avatar)}`;
190
190
  }
191
191
 
192
- async function modelInfoMap(client) {
192
+ async function modelCatalog(client) {
193
193
  const cacheKey = client.apiBase || 'default';
194
194
  const hit = infoCache.get(cacheKey);
195
- if (hit && Date.now() - hit.at < ICON_TTL_MS) return hit.byKey;
195
+ if (hit && Date.now() - hit.at < ICON_TTL_MS) return hit;
196
196
  const byKey = new Map();
197
+ const all = [];
197
198
  try {
198
199
  const res = await client.request('GET', '/v1/models');
199
200
  const models = res?.models || res?.data?.models || [];
@@ -206,10 +207,17 @@ async function modelInfoMap(client) {
206
207
  // `name` is the CLEAN display name ("Google TTS"); it is what widgets show.
207
208
  // Without it the model chip fell back to whatever raw string the caller or
208
209
  // the status endpoint supplied ("google_tts", "fal-ai/bytedance/omnihuman/v1.5").
209
- const info = { icon, eta, id: m.identifier || null, name: m.name || null };
210
+ // `types` is the catalog `type` array ("text_to_video", "img_to_video", …)
211
+ // the ONLY thing that tells two same-named variants apart. See canonicalModelId.
212
+ const raw = m.types !== undefined ? m.types : m.type;
213
+ const types = (Array.isArray(raw) ? raw : [raw]).filter(Boolean).map(String);
214
+ const info = { icon, eta, id: m.identifier || null, name: m.name || null, types };
215
+ all.push(info);
210
216
  // Display names collide across variants ("Nano Banana 2" names both the
211
217
  // t2i model and its editing sibling) — on collision keep the model with
212
- // the SHORTEST identifier (the base model), deterministically.
218
+ // the SHORTEST identifier (the base model), deterministically. This map is
219
+ // for ICONS/ETAs, where the variant doesn't matter; identifier resolution
220
+ // must NOT use it (that is what made "Kling 2.6 Pro" always mean the t2v one).
213
221
  const setName = (k) => {
214
222
  const prev = byKey.get(k);
215
223
  if (!prev || !prev.id || (info.id && info.id.length < prev.id.length)) byKey.set(k, info);
@@ -220,11 +228,16 @@ async function modelInfoMap(client) {
220
228
  } catch (_) {
221
229
  /* fail open — widgets fall back to monogram chips, no ETA */
222
230
  }
231
+ const entry = { at: Date.now(), byKey, all };
223
232
  // Never cache an empty map: the first request in a fresh worker (typical
224
233
  // right after a deploy restart) can fail transiently, and caching that
225
234
  // failure blanks every model icon for the TTL window.
226
- if (byKey.size > 0) infoCache.set(cacheKey, { at: Date.now(), byKey });
227
- return byKey;
235
+ if (byKey.size > 0) infoCache.set(cacheKey, entry);
236
+ return entry;
237
+ }
238
+
239
+ async function modelInfoMap(client) {
240
+ return (await modelCatalog(client)).byKey;
228
241
  }
229
242
 
230
243
  /** Resolve one model's { icon, eta, name }; missing → all null. */
@@ -291,6 +304,22 @@ const AUTO_ALIASES = new Set([
291
304
  'auto', 'autoselect', 'smartselect', 'kolbosmartselectrouter', 'default', 'none',
292
305
  ]);
293
306
 
307
+ /**
308
+ * Narrow several models that answer to the same string down to one identifier.
309
+ * The CALLING TOOL's catalog type decides: a display name like "Kling 2.6 Pro"
310
+ * names one model PER MODALITY (…/text-to-video and …/image-to-video), and only
311
+ * the caller knows which it wants. No type match (or no type given) → shortest
312
+ * identifier, the same deterministic tiebreak the icon map uses.
313
+ */
314
+ function pickForType(candidates, types) {
315
+ if (!candidates.length) return null;
316
+ const typed = types.length
317
+ ? candidates.filter((i) => i.types.some((t) => types.includes(t)))
318
+ : [];
319
+ const pool = typed.length ? typed : candidates;
320
+ return pool.reduce((a, b) => (b.id.length < a.id.length ? b : a)).id;
321
+ }
322
+
294
323
  /**
295
324
  * Lenient model-identifier resolution for LLM-supplied model args.
296
325
  * Users say "z-image"; the real identifier is "z-image/turbo" — the backend
@@ -299,6 +328,12 @@ const AUTO_ALIASES = new Set([
299
328
  * else a separator-insensitive hit ("flux-2-flash" → "flux-2/flash"); else a
300
329
  * UNIQUE prefix match ("z-image" → "z-image/turbo").
301
330
  *
331
+ * `type` is the calling tool's catalog type (a string, or an array when the
332
+ * tool spans several — lipsync, 3D). It is what makes resolution MODALITY-AWARE:
333
+ * without it, "Kling 2.6 Pro" from generate_video_from_image resolved to
334
+ * kling-video/v2.6/pro/text-to-video (2026-08-10), so the image-to-video
335
+ * pipeline submitted the TEXT-to-video endpoint and billed against it.
336
+ *
302
337
  * Still unresolved: throw with the near misses named. The API answers a bad
303
338
  * identifier with a bare INVALID_*_MODEL and no hint, which on 2026-08-09 sent
304
339
  * an agent guessing "minimax-hailuo-3" (real id: "minimax-h3") and then
@@ -307,43 +342,46 @@ const AUTO_ALIASES = new Set([
307
342
  * unchanged, so identifiers the catalog does not publish (hidden models) still
308
343
  * reach the API and it stays the source of truth.
309
344
  */
310
- async function canonicalModelId(client, input) {
345
+ async function canonicalModelId(client, input, type) {
311
346
  if (!input || typeof input !== 'string') return input;
312
347
  const key = input.toLowerCase().trim();
313
348
  const want = normId(key);
314
349
  if (!want || AUTO_ALIASES.has(want)) return input;
315
350
 
316
- let map;
351
+ let all;
317
352
  try {
318
- map = await modelInfoMap(client);
353
+ all = (await modelCatalog(client)).all;
319
354
  } catch (_) {
320
355
  return input; // fail open — never block a generation on a catalog hiccup
321
356
  }
322
- if (!map || map.size === 0) return input;
357
+ const models = (all || []).filter((i) => i.id);
358
+ if (!models.length) return input;
359
+
360
+ const types = (Array.isArray(type) ? type : [type]).filter(Boolean);
361
+ const dashed = key.replace(/\s+/g, '-');
323
362
 
324
363
  // 1. exact name / identifier hit
325
- const hit = map.get(key) || map.get(key.replace(/\s+/g, '-'));
326
- if (hit && hit.id) return hit.id;
327
-
328
- // 2 + 3. separator-insensitive exact, then unique prefix
329
- const byNorm = new Map();
330
- for (const info of map.values()) {
331
- if (!info.id) continue;
332
- for (const k of [info.id, info.name]) {
333
- const n = normId(k);
334
- if (n && !byNorm.has(n)) byNorm.set(n, info.id);
335
- }
336
- }
337
- if (byNorm.has(want)) return byNorm.get(want);
338
- const prefixed = new Set();
339
- for (const [n, id] of byNorm) if (n.startsWith(want)) prefixed.add(id);
340
- if (prefixed.size === 1) return [...prefixed][0];
364
+ const exact = pickForType(models.filter((i) => [i.id, i.name].some(
365
+ (k) => k && (k.toLowerCase() === key || k.toLowerCase() === dashed)
366
+ )), types);
367
+ if (exact) return exact;
368
+
369
+ // 2. separator-insensitive exact ("flux-2-flash" → "flux-2/flash")
370
+ const loose = pickForType(models.filter((i) => normId(i.id) === want || normId(i.name) === want), types);
371
+ if (loose) return loose;
372
+
373
+ // 3. unique prefix ("z-image" "z-image/turbo") the modality filter runs
374
+ // FIRST, so a stem shared by a t2v/i2v pair is no longer ambiguous.
375
+ const prefixed = models.filter((i) => normId(i.id).startsWith(want) || normId(i.name).startsWith(want));
376
+ const narrowed = types.length ? prefixed.filter((i) => i.types.some((t) => types.includes(t))) : [];
377
+ const ids = new Set((narrowed.length ? narrowed : prefixed).map((i) => i.id));
378
+ if (ids.size === 1) return [...ids][0];
341
379
 
342
380
  // 4. unknown — name the near misses instead of dead-ending at the API.
343
381
  const stem = normId(key.split(/[\s._/-]+/).filter(Boolean)[0] || key);
344
382
  const near = [...new Set(
345
- [...map.values()]
346
- .filter((i) => i.id && stem && (normId(i.id).startsWith(stem) || normId(i.name).startsWith(stem)))
383
+ models
384
+ .filter((i) => stem && (normId(i.id).startsWith(stem) || normId(i.name).startsWith(stem)))
347
385
  .map((i) => (i.name ? `${i.id} (${i.name})` : i.id))
348
386
  )].sort().slice(0, 12);
349
387
  if (!near.length) return input;
package/src/tools/chat.js CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  const { z } = require('zod');
7
7
  const { pollOrTimedOut, creditFields, projectIdField } = require('./_shared');
8
+ const { canonicalModelId } = require('../apps');
8
9
 
9
10
  function registerChatTools(server, client) {
10
11
  // ─── chat_send_message ─────────────────────────────────────
@@ -13,7 +14,7 @@ function registerChatTools(server, client) {
13
14
  'Send a chat message to Kolbo AI. Starts a new conversation (omit session_id) or continues an existing one. Returns the assistant response when complete. Supports image/video/audio analysis via media_urls — pass public URLs and the model auto-routes to a vision-capable model (e.g. Gemini) when media is detected. Supports web search and deep think modes.',
14
15
  {
15
16
  message: z.string().describe('The user message to send'),
16
- model: z.string().optional().describe('Model identifier (e.g. "gpt-4o", "claude-sonnet-4-6"). Prefer passing a SPECIFIC model (list_models type="text") — omitting falls back to Smart Select auto-routing, which we avoid unless the user explicitly asks for auto-pick. Exception: when media_urls contains video or audio, omitting is fine — routing goes to a Gemini vision model regardless of this field.'),
17
+ model: z.string().optional().describe('Model identifier from list_models type="text". Identifiers resolve leniently, so the DISPLAY NAME that list_models shows works too ("Grok 4.5" → its identifier). Do NOT hardcode an id you have not seen in list_models — the text catalog turns over fast. Prefer passing a SPECIFIC model — omitting falls back to Smart Select auto-routing, which we avoid unless the user explicitly asks for auto-pick. Exception: when media_urls contains video or audio, omitting is fine — routing goes to a Gemini vision model regardless of this field.'),
17
18
  session_id: z.string().optional().describe('Existing chat session ID to continue. Omit to start a new conversation.'),
18
19
  system_prompt: z.string().optional().describe('System prompt for the conversation. Only applied when creating a new session.'),
19
20
  web_search: z.boolean().optional().describe('Enable web search for this message. Default: false'),
@@ -23,6 +24,13 @@ function registerChatTools(server, client) {
23
24
  project_id: projectIdField
24
25
  },
25
26
  async ({ message, model, session_id, system_prompt, web_search, deep_think, enhance_prompt = false, media_urls, project_id }) => {
27
+ // Every generate_* tool resolves its model this way; chat was the one
28
+ // `model` arg that went straight to the API, which has no fuzzy matching.
29
+ // So the display names list_models hands back ("Claude Fable 5") came
30
+ // back as a bare `Model not found: Claude Fable 5 [MODEL_NOT_FOUND]` —
31
+ // discovery had no path to use.
32
+ model = await canonicalModelId(client, model, 'text'); // lenient id resolution ("Grok 4.5" → its identifier)
33
+
26
34
  const gen = await client.post('/v1/chat', {
27
35
  message,
28
36
  model,
@@ -139,7 +139,7 @@ function registerGenerateTools(server, client, options = {}) {
139
139
  },
140
140
  async ({ prompt, prompts, model, aspect_ratio, enhance_prompt = false, num_images, reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id }) => {
141
141
  if (!prompt && !(prompts && prompts.length)) throw new Error('Provide prompt or prompts');
142
- model = await canonicalModelId(client, model); // lenient id resolution ("z-image" → "z-image/turbo")
142
+ model = await canonicalModelId(client, model, 'text_to_img'); // lenient id resolution ("z-image" → "z-image/turbo")
143
143
  const shared = {
144
144
  model, aspect_ratio, enhance_prompt,
145
145
  reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id
@@ -210,7 +210,7 @@ function registerGenerateTools(server, client, options = {}) {
210
210
  project_id: projectIdField
211
211
  },
212
212
  async ({ prompt, model, source_images, aspect_ratio, enhance_prompt = false, num_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, cinematic, skip_color_palette, project_id }) => {
213
- model = await canonicalModelId(client, model); // lenient id resolution ("z-image" → "z-image/turbo")
213
+ model = await canonicalModelId(client, model, 'image_editing'); // lenient id resolution ("z-image" → "z-image/turbo")
214
214
  const gen = await client.post('/v1/generate/image-edit', {
215
215
  prompt, model, source_images, aspect_ratio, enhance_prompt, num_images,
216
216
  visual_dna_ids, moodboard_id, enable_web_search, resolution, cinematic, skip_color_palette, project_id
@@ -270,7 +270,7 @@ function registerGenerateTools(server, client, options = {}) {
270
270
  project_id: projectIdField
271
271
  },
272
272
  async ({ prompt, scene_count, model, aspect_ratio, workflow_type, duration, enhance_prompt = false, reference_images, visual_dna_ids, moodboard_id, moodboard_ids, resolution, project_id }) => {
273
- model = await canonicalModelId(client, model); // lenient id resolution ("z-image" → "z-image/turbo")
273
+ model = await canonicalModelId(client, model, workflow_type === 'video' ? 'text_to_video' : 'text_to_img'); // lenient id resolution ("z-image" → "z-image/turbo")
274
274
  const gen = await client.post('/v1/generate/creative-director', {
275
275
  prompt, scene_count, model, aspect_ratio, workflow_type, duration,
276
276
  enhance_prompt, reference_images, visual_dna_ids, moodboard_id, moodboard_ids, resolution, project_id
@@ -423,7 +423,7 @@ function registerGenerateTools(server, client, options = {}) {
423
423
  },
424
424
  async ({ prompt, prompts, model, aspect_ratio, duration, enhance_prompt = false, reference_images, resolution, preset_id, sound_enabled, skip_color_palette, project_id }) => {
425
425
  if (!prompt && !(prompts && prompts.length)) throw new Error('Provide prompt or prompts');
426
- model = await canonicalModelId(client, model); // lenient id resolution ("z-image" → "z-image/turbo")
426
+ model = await canonicalModelId(client, model, 'text_to_video'); // lenient id resolution ("z-image" → "z-image/turbo")
427
427
  const shared = {
428
428
  model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, preset_id, sound_enabled, skip_color_palette, project_id
429
429
  };
@@ -495,7 +495,7 @@ function registerGenerateTools(server, client, options = {}) {
495
495
  project_id: projectIdField
496
496
  },
497
497
  async ({ image_url, prompt, model, aspect_ratio, duration, enhance_prompt = false, visual_dna_ids, resolution, sound_enabled, skip_color_palette, project_id }) => {
498
- model = await canonicalModelId(client, model); // lenient id resolution ("z-image" → "z-image/turbo")
498
+ model = await canonicalModelId(client, model, 'img_to_video'); // lenient id resolution ("z-image" → "z-image/turbo")
499
499
  const gen = await client.post('/v1/generate/video/from-image', {
500
500
  image_url, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution, sound_enabled, skip_color_palette, project_id
501
501
  });
@@ -558,7 +558,7 @@ function registerGenerateTools(server, client, options = {}) {
558
558
  project_id: projectIdField
559
559
  },
560
560
  async ({ prompt, model, style, title, instrumental, lyrics, vocal_gender, negative_tags, duration_seconds, enhance_prompt = false, preset_id, style_weight, weirdness, audio_weight, persona_id, use_composition_plan, singing_dna_id, singing_voice_id, project_id }) => {
561
- model = await canonicalModelId(client, model); // lenient id resolution ("z-image" → "z-image/turbo")
561
+ model = await canonicalModelId(client, model, 'music_gen'); // lenient id resolution ("z-image" → "z-image/turbo")
562
562
  const gen = await client.post('/v1/generate/music', {
563
563
  prompt, model, style, title, instrumental, lyrics, vocal_gender, negative_tags,
564
564
  duration_seconds, enhance_prompt, preset_id,
@@ -630,7 +630,7 @@ function registerGenerateTools(server, client, options = {}) {
630
630
  project_id: projectIdField
631
631
  },
632
632
  async ({ text, voice, model, language, style_instructions, selected_style, emotion, speaking_speed, similarity_boost, style, use_speaker_boost, variance, tempo, promptBoost, seed, accentControl, voiceTitle, minimax_pitch, minimax_vol, minimax_intensity, minimax_timbre, project_id }) => {
633
- model = await canonicalModelId(client, model); // lenient id resolution ("z-image" → "z-image/turbo")
633
+ model = await canonicalModelId(client, model, 'text_to_speech'); // lenient id resolution ("z-image" → "z-image/turbo")
634
634
  // Resolve the requested voice against the REAL catalog (cached) so the card
635
635
  // can show its display name + portrait instead of a raw id, and so an id
636
636
  // that does not exist is reported instead of rendering silently: Google
@@ -704,7 +704,7 @@ function registerGenerateTools(server, client, options = {}) {
704
704
  project_id: projectIdField
705
705
  },
706
706
  async ({ prompt, model, duration, prompt_influence, cfg_strength, sound_loop, sound_tempo, sound_key, seed_voice, seed_speed, seed_volume, seed_pitch, seed_reference_audio_urls, seed_reference_image_url, project_id }) => {
707
- model = await canonicalModelId(client, model); // lenient id resolution ("z-image" → "z-image/turbo")
707
+ model = await canonicalModelId(client, model, 'text_to_sound'); // lenient id resolution ("z-image" → "z-image/turbo")
708
708
  const gen = await client.post('/v1/generate/sound', {
709
709
  prompt, model, duration, prompt_influence,
710
710
  cfg_strength, sound_loop, sound_tempo, sound_key,
@@ -920,7 +920,7 @@ function registerGenerateTools(server, client, options = {}) {
920
920
  project_id: projectIdField
921
921
  },
922
922
  async ({ prompt, model, reference_images, reference_videos, reference_audio_urls, audio_url, files, duration, aspect_ratio, motion, preset_id, enhance_prompt = false, visual_dna_ids, resolution, keyframes, project_id }) => {
923
- model = await canonicalModelId(client, model); // lenient id resolution ("z-image" → "z-image/turbo")
923
+ model = await canonicalModelId(client, model, 'elements'); // lenient id resolution ("z-image" → "z-image/turbo")
924
924
  if (!prompt) throw new Error('prompt is required');
925
925
 
926
926
  let startResponse;
@@ -1001,7 +1001,7 @@ function registerGenerateTools(server, client, options = {}) {
1001
1001
  project_id: projectIdField
1002
1002
  },
1003
1003
  async ({ first_frame_url, last_frame_url, first_frame, last_frame, prompt, model, duration, aspect_ratio, enhance_prompt = false, visual_dna_ids, resolution, project_id }) => {
1004
- model = await canonicalModelId(client, model); // lenient id resolution ("z-image" → "z-image/turbo")
1004
+ model = await canonicalModelId(client, model, 'firstlastgenerations'); // lenient id resolution ("z-image" → "z-image/turbo")
1005
1005
  const urlMode = first_frame_url && last_frame_url;
1006
1006
  const fileMode = first_frame && last_frame;
1007
1007
  if (!urlMode && !fileMode) {
@@ -1091,7 +1091,7 @@ function registerGenerateTools(server, client, options = {}) {
1091
1091
  project_id: projectIdField
1092
1092
  },
1093
1093
  async ({ source, audio, text_prompt, model, bounding_box_target, sync_mode, model_mode, emotion, temperature, occlusion_detection_enabled, active_speaker_detection, project_id }) => {
1094
- model = await canonicalModelId(client, model); // lenient id resolution ("z-image" → "z-image/turbo")
1094
+ model = await canonicalModelId(client, model, ['lipsync-image', 'lipsync-video']); // lenient id resolution ("z-image" → "z-image/turbo")
1095
1095
  if (!source) throw new Error('source is required (URL or absolute local path to image/video)');
1096
1096
  if (!audio) throw new Error('audio is required (URL or absolute local path to audio file)');
1097
1097
 
@@ -1212,7 +1212,7 @@ function registerGenerateTools(server, client, options = {}) {
1212
1212
  project_id: projectIdField
1213
1213
  },
1214
1214
  async ({ source_video, prompt, model, aspect_ratio, duration, enhance_prompt = false, visual_dna_ids, resolution, reference_images, reference_videos, elements, preset, source_language, translation_language, srt_content, srt_file_url, vocabulary, customization, project_id }) => {
1215
- model = await canonicalModelId(client, model); // lenient id resolution ("z-image" → "z-image/turbo")
1215
+ model = await canonicalModelId(client, model, 'video_to_video'); // lenient id resolution ("z-image" → "z-image/turbo")
1216
1216
  if (!source_video) throw new Error('source_video is required');
1217
1217
 
1218
1218
  const isUrl = /^https?:\/\//i.test(source_video);
@@ -1371,7 +1371,7 @@ function registerGenerateTools(server, client, options = {}) {
1371
1371
  project_id: projectIdField
1372
1372
  },
1373
1373
  async ({ prompt, reference_images, mode, texture_prompt, model, topology, target_polycount, enable_tpose, enable_pbr, project_id }) => {
1374
- model = await canonicalModelId(client, model); // lenient id resolution ("z-image" → "z-image/turbo")
1374
+ model = await canonicalModelId(client, model, ['3d_text_to_model', '3d_image_to_model', '3d_multi_image_to_model', '3d_world']); // lenient id resolution ("z-image" → "z-image/turbo")
1375
1375
  if (!prompt && !(reference_images && reference_images.length > 0)) {
1376
1376
  throw new Error('Provide prompt (text mode) or reference_images (single/multi mode)');
1377
1377
  }
@@ -1514,6 +1514,9 @@ function registerGenerateTools(server, client, options = {}) {
1514
1514
  zoom_out_percentage, expand_left, expand_right, expand_top, expand_bottom,
1515
1515
  project_id
1516
1516
  }) => {
1517
+ // No `type` argument: these are operation-routed tools (upscale / reframe /
1518
+ // removebg / …), each operation with its own model family — there is no single
1519
+ // catalog type to disambiguate against.
1517
1520
  model = await canonicalModelId(client, model);
1518
1521
 
1519
1522
  // Basic validation
@@ -1667,6 +1670,9 @@ function registerGenerateTools(server, client, options = {}) {
1667
1670
  start_time,
1668
1671
  project_id
1669
1672
  }) => {
1673
+ // No `type` argument: these are operation-routed tools (upscale / reframe /
1674
+ // removebg / …), each operation with its own model family — there is no single
1675
+ // catalog type to disambiguate against.
1670
1676
  model = await canonicalModelId(client, model);
1671
1677
 
1672
1678
  // Validation
@@ -67,6 +67,10 @@ function buildCatalogStructured(models, type, compact) {
67
67
  if (g.models.length >= 6) continue; // curated cap — full list lives in the text payload
68
68
  g.models.push({
69
69
  name: m.name,
70
+ // The widget renders `name`; the AGENT reads the same rows (hosts hand it
71
+ // structuredContent). Without the identifier the default call was a dead
72
+ // end — it named six models and gave no way to pass any of them on.
73
+ identifier: m.identifier,
70
74
  icon: resolveAvatarUrl(m.avatar),
71
75
  description: String(m.smartSelect_StrengthsSummary || m.summary || m.description || '').slice(0, 90),
72
76
  chips: modelChips(m),
@@ -12,21 +12,34 @@ 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.',
16
- {},
17
- async () => {
18
- const result = await client.get('/v1/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, is_default, and is_archived. The project flagged `is_default: true` is the auto-created "API Generations" bucket every SDK generation lands in when project_id is omitted. Accounts routinely have HUNDREDS of projects, so this is paginated: when you already know the name, pass `search` — it is far cheaper than listing everything. Default page size is 50; use `page` to walk the rest (`pagination.has_more` tells you when to stop). Archived projects are hidden unless you pass `include_archived: true`.',
16
+ {
17
+ search: z.string().optional().describe('Case-insensitive substring match on the project name. Use this whenever the user named a project — it turns a full listing into a one-item answer.'),
18
+ page: z.number().optional().describe('Page number, 1-indexed. Default: 1'),
19
+ limit: z.number().optional().describe('Results per page, max 200. Default: 50'),
20
+ include_archived: z.boolean().optional().describe('Also return archived projects. Default false — archived projects are hidden here exactly as they are in the web app.')
21
+ },
22
+ async ({ search, page, limit, include_archived }) => {
23
+ const params = new URLSearchParams();
24
+ if (search) params.set('search', search);
25
+ if (page) params.set('page', String(page));
26
+ if (limit) params.set('limit', String(limit));
27
+ if (include_archived) params.set('include_archived', 'true');
28
+ const qs = params.toString();
29
+ const result = await client.get(`/v1/projects${qs ? '?' + qs : ''}`);
19
30
  const projects = (result.projects || []).map(p => ({
20
31
  id: p.id,
21
32
  name: p.name,
22
33
  role: p.role,
23
34
  is_default: !!p.is_default,
35
+ is_archived: !!p.is_archived,
24
36
  open_url: buildProjectUrl(p.id, { is_default: !!p.is_default })
25
37
  }));
26
38
  const text = JSON.stringify({
27
39
  projects,
28
40
  count: projects.length,
29
- _hint: 'Pass the chosen `id` as `project_id` on any generate_* tool to drop the generation into that project. Omit project_id to use the project flagged is_default:true. `open_url` opens that project\'s media in the web app (share it with the user).'
41
+ pagination: result.pagination || null,
42
+ _hint: 'Pass the chosen `id` as `project_id` on any generate_* tool to drop the generation into that project. Omit project_id to use the project flagged is_default:true. `open_url` opens that project\'s media in the web app (share it with the user). If `pagination.has_more` is true there are more projects — narrow with `search` rather than paging through everything.'
30
43
  }, null, 2);
31
44
 
32
45
  if (ui()) {
@@ -36,7 +49,7 @@ function registerProjectTools(server, client, options = {}) {
36
49
  items: projects.map(p => ({
37
50
  id: p.id,
38
51
  title: p.name,
39
- subtitle: p.role + (p.is_default ? ' · default' : ''),
52
+ subtitle: p.role + (p.is_default ? ' · default' : '') + (p.is_archived ? ' · archived' : ''),
40
53
  open_url: p.open_url,
41
54
  use_hint: 'Use my "{TITLE}" project (project_id: {ID}) for what I do next.'
42
55
  })),
@@ -87,15 +87,19 @@ function registerVisualDnaTools(server, client, options = {}) {
87
87
  + 'because dumping them buries the user\'s own handful. Only pass scope="global" (optionally with '
88
88
  + '`collection` and `search`) when the user explicitly wants to BROWSE the preset cast — e.g. "find me a '
89
89
  + 'character", "show me street style models", "I need a location DNA" — and they have not named one of '
90
- + 'their own. Use scope="all" only if the user genuinely wants both at once.',
90
+ + 'their own. Use scope="all" only if the user genuinely wants both at once. '
91
+ + 'The response reports `total` and `_truncated`; when there are more matches than one '
92
+ + 'page holds, raise `limit` or walk `page` — do not tell the user the extras do not exist.',
91
93
  {
92
94
  scope: z.enum(['all', 'personal', 'global', 'organization']).optional().describe('Default: "personal" — the user\'s own DNAs (plus a shared project\'s when project_id is set). "global" = the ~1000 system cast/preset DNAs, for browsing when the user needs a character and has none of their own. "organization" = org-shared. "all" = everything, rarely wanted.'),
93
- search: z.string().optional().describe('Search by name, tags, or description (case-insensitive)'),
95
+ search: z.string().optional().describe('Search by name, tags, or description (case-insensitive). Matches at WORD STARTS, so "man" finds "Man"/"Manager" but not "woman" or "romantic". Name matches are ranked first.'),
94
96
  collection: z.string().optional().describe('Filter global presets by collection: cast, influencers, props, locations, styles, glamour, street'),
95
97
  tags: z.string().optional().describe('Comma-separated tags to filter by (OR logic)'),
98
+ page: z.number().optional().describe('Page number, 1-indexed. Default: 1. Needed to reach the global cast beyond the first page.'),
99
+ limit: z.number().optional().describe('Results per page, max 100. Default: 50'),
96
100
  project_id: projectScopeReadField
97
101
  },
98
- async ({ scope, search, collection, tags, project_id } = {}) => {
102
+ async ({ scope, search, collection, tags, page, limit, project_id } = {}) => {
99
103
  const params = new URLSearchParams();
100
104
  // Default to the user's OWN DNAs. The API defaults to "all", which pulls
101
105
  // in ~1000 global cast presets and buries the handful the user actually
@@ -106,17 +110,23 @@ function registerVisualDnaTools(server, client, options = {}) {
106
110
  if (search) params.set('search', search);
107
111
  if (collection) params.set('collection', collection);
108
112
  if (tags) params.set('tags', tags);
113
+ // Always paged. Without these the ~1000-item global cast came back whole and
114
+ // was silently cut to the display cap, so nothing past the first screen was
115
+ // reachable through this tool at all.
116
+ params.set('page', String(page && page > 0 ? Math.floor(page) : 1));
117
+ params.set('limit', String(limit && limit > 0 ? Math.min(Math.floor(limit), 100) : 50));
109
118
  if (project_id) params.set('project_id', project_id);
110
119
  const qs = params.toString();
111
120
  const result = await client.get(`/v1/visual-dna${qs ? '?' + qs : ''}`);
112
121
  const dnas = result.visual_dnas || [];
122
+ const total = result.total != null ? result.total : (result.count || dnas.length);
113
123
  // Full profiles measured 74,310 chars — the embedded analysis/description
114
124
  // blobs are large and the model only needs enough to pick an id.
115
125
  const text = compactList(dnas, {
116
126
  fields: ['id', 'name', 'type', 'folder_id', 'tags', 'thumbnail'],
117
127
  cap: 60,
118
- total: result.count || dnas.length,
119
- note: 'Narrow with `search`, `tags`, or `collection`; get_visual_dna returns one in full.',
128
+ total,
129
+ note: 'Narrow with `search`, `tags`, or `collection`, or pass `page`/`limit` for the rest; get_visual_dna returns one in full.',
120
130
  });
121
131
 
122
132
  if (ui()) {
@@ -131,8 +141,8 @@ function registerVisualDnaTools(server, client, options = {}) {
131
141
  media_type: 'image',
132
142
  use_hint: 'Use Visual DNA "{TITLE}" (id: {ID}) in my next generation for character/style consistency.'
133
143
  })),
134
- total: result.count || dnas.length,
135
- has_more: dnas.length > 24
144
+ total,
145
+ has_more: result.has_more || dnas.length > 24
136
146
  });
137
147
  }
138
148