@kolbo/mcp 1.59.0 → 1.60.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.59.0",
3
+ "version": "1.60.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": {
@@ -10,7 +10,8 @@
10
10
  "start": "node src/index.js",
11
11
  "smoke": "node scripts/smoke.js",
12
12
  "check-parity": "node scripts/check-parity.js",
13
- "prepublishOnly": "node scripts/smoke.js && node scripts/check-parity.js && node scripts/check-widget-fields.js && node scripts/check-widget-render.js && node scripts/check-skill-tools.js && node scripts/check-install.js",
13
+ "prepublishOnly": "node scripts/smoke.js && node scripts/check-parity.js && node scripts/check-widget-fields.js && node scripts/check-widget-render.js && node scripts/check-model-catalog.js && node scripts/check-skill-tools.js && node scripts/check-install.js",
14
+ "check-model-catalog": "node scripts/check-model-catalog.js",
14
15
  "check-widget-fields": "node scripts/check-widget-fields.js",
15
16
  "check-widget-render": "node scripts/check-widget-render.js",
16
17
  "check-skill-tools": "node scripts/check-skill-tools.js",
package/src/apps/index.js CHANGED
@@ -279,29 +279,79 @@ async function modelIcon(client, modelName) {
279
279
  return (await modelInfo(client, modelName)).icon;
280
280
  }
281
281
 
282
+ // Separator-insensitive key. Catalog keys carry their own punctuation — the
283
+ // NAME is keyed "minimax h3", the IDENTIFIER "flux-2/flash" — so both sides
284
+ // must be flattened before comparing. Normalising only the input (the old
285
+ // `key.replace(/\s+/g, '-')`) is why "flux-2-flash" never found "flux-2/flash".
286
+ const normId = (s) => String(s || '').toLowerCase().replace(/[\s._/-]+/g, '');
287
+
288
+ // The API maps these to Smart Select itself. They are never typos, so they must
289
+ // never be "corrected" or reported as unknown.
290
+ const AUTO_ALIASES = new Set([
291
+ 'auto', 'autoselect', 'smartselect', 'kolbosmartselectrouter', 'default', 'none',
292
+ ]);
293
+
282
294
  /**
283
295
  * Lenient model-identifier resolution for LLM-supplied model args.
284
296
  * Users say "z-image"; the real identifier is "z-image/turbo" — the backend
285
297
  * has no fuzzy matching on generation routes and fails deep in credit
286
298
  * reservation. Resolve here: exact name/identifier hit → its identifier;
287
- * else a UNIQUE identifier prefix match ("z-image" → "z-image/turbo");
288
- * ambiguous or unknown → pass through unchanged (API stays source of truth).
299
+ * else a separator-insensitive hit ("flux-2-flash" → "flux-2/flash"); else a
300
+ * UNIQUE prefix match ("z-image" "z-image/turbo").
301
+ *
302
+ * Still unresolved: throw with the near misses named. The API answers a bad
303
+ * identifier with a bare INVALID_*_MODEL and no hint, which on 2026-08-09 sent
304
+ * an agent guessing "minimax-hailuo-3" (real id: "minimax-h3") and then
305
+ * substituting a far more expensive model. Only throws when the catalog is
306
+ * healthy AND actually offers candidates — otherwise it passes through
307
+ * unchanged, so identifiers the catalog does not publish (hidden models) still
308
+ * reach the API and it stays the source of truth.
289
309
  */
290
310
  async function canonicalModelId(client, input) {
291
311
  if (!input || typeof input !== 'string') return input;
312
+ const key = input.toLowerCase().trim();
313
+ const want = normId(key);
314
+ if (!want || AUTO_ALIASES.has(want)) return input;
315
+
316
+ let map;
292
317
  try {
293
- const map = await modelInfoMap(client);
294
- const key = input.toLowerCase().trim();
295
- const hit = map.get(key) || map.get(key.replace(/\s+/g, '-'));
296
- if (hit && hit.id) return hit.id;
297
- const ids = new Set();
298
- for (const info of map.values()) {
299
- const id = (info.id || '').toLowerCase();
300
- if (id && (id.startsWith(key + '/') || id.startsWith(key + '-'))) ids.add(info.id);
318
+ map = await modelInfoMap(client);
319
+ } catch (_) {
320
+ return input; // fail open never block a generation on a catalog hiccup
321
+ }
322
+ if (!map || map.size === 0) return input;
323
+
324
+ // 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);
301
335
  }
302
- if (ids.size === 1) return [...ids][0];
303
- } catch (_) { /* fail open */ }
304
- return input;
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];
341
+
342
+ // 4. unknown — name the near misses instead of dead-ending at the API.
343
+ const stem = normId(key.split(/[\s._/-]+/).filter(Boolean)[0] || key);
344
+ const near = [...new Set(
345
+ [...map.values()]
346
+ .filter((i) => i.id && stem && (normId(i.id).startsWith(stem) || normId(i.name).startsWith(stem)))
347
+ .map((i) => (i.name ? `${i.id} (${i.name})` : i.id))
348
+ )].sort().slice(0, 12);
349
+ if (!near.length) return input;
350
+ throw new Error(
351
+ `Unknown model identifier "${input}". Did you mean: ${near.join(', ')}? `
352
+ + 'Never guess an identifier — call list_models with the matching `type` and `format: "json"` '
353
+ + 'to get the exact identifiers and caps.'
354
+ );
305
355
  }
306
356
 
307
357
  /* ------------------------------------------------------------------ */
@@ -42,9 +42,20 @@ const CINEMATIC_SCHEMA = z.object({
42
42
  // The manual-control twin of generate_creative_director: no orchestration pass,
43
43
  // the user's exact prompts verbatim. Submit failures never sink the batch —
44
44
  // successful ids proceed, failed prompts are reported alongside.
45
+ // Over the cap is a hard rejection, never a truncation: this used to
46
+ // `.slice(0, MAX_BATCH_PROMPTS)`, so a 9-prompt call silently generated 8 and
47
+ // the caller had no way to know which prompt vanished. `promptsField` also caps
48
+ // the array in the schema (so hosts see `maxItems` before calling); the guard
49
+ // below is the choke point EVERY batch tool routes through, and names the count.
45
50
  const MAX_BATCH_PROMPTS = 8;
46
51
  async function submitBatch(rawPrompts, submitOne) {
47
- const prompts = rawPrompts.slice(0, MAX_BATCH_PROMPTS).map((s) => String(s).trim()).filter(Boolean);
52
+ if (rawPrompts.length > MAX_BATCH_PROMPTS) {
53
+ throw new Error(
54
+ `Too many prompts: ${rawPrompts.length} received, max ${MAX_BATCH_PROMPTS} per call. ` +
55
+ `Split them across ${Math.ceil(rawPrompts.length / MAX_BATCH_PROMPTS)} calls of at most ${MAX_BATCH_PROMPTS}.`
56
+ );
57
+ }
58
+ const prompts = rawPrompts.map((s) => String(s).trim()).filter(Boolean);
48
59
  const settled = await Promise.allSettled(prompts.map((p) => submitOne(p)));
49
60
  const ok = [], failed = [];
50
61
  settled.forEach((s, i) => {
@@ -91,8 +102,8 @@ const imageSettings = (a = {}) => ({
91
102
  cinematic: a.cinematic ? true : undefined,
92
103
  });
93
104
 
94
- const promptsField = (what) => z.array(z.string()).optional().describe(
95
- `BATCH MODE — several DIFFERENT prompts (2–${MAX_BATCH_PROMPTS}) generated concurrently in ONE call and rendered together in ONE combined widget. Whenever the user wants multiple distinct ${what} with their own prompts, ALWAYS pass them all here instead of making several separate calls — separate calls clutter the chat with stacked widgets. All prompts share the same model/settings. When set, \`prompt\` is ignored. For N variations of a SINGLE prompt use num_images (image tools); for an AI-planned coherent scene set use generate_creative_director.`
105
+ const promptsField = (what) => z.array(z.string()).max(MAX_BATCH_PROMPTS).optional().describe(
106
+ `BATCH MODE — several DIFFERENT prompts (2–${MAX_BATCH_PROMPTS}) generated concurrently in ONE call and rendered together in ONE combined widget. **Hard cap: ${MAX_BATCH_PROMPTS} prompts per call — more than that is REJECTED with an error (never silently truncated), so split a longer list across several calls of at most ${MAX_BATCH_PROMPTS}.** Whenever the user wants multiple distinct ${what} with their own prompts, ALWAYS pass them all here instead of making several separate calls — separate calls clutter the chat with stacked widgets. All prompts share the same model/settings. When set, \`prompt\` is ignored. For N variations of a SINGLE prompt use num_images (image tools); for an AI-planned coherent scene set use generate_creative_director.`
96
107
  );
97
108
 
98
109
  function registerGenerateTools(server, client, options = {}) {
@@ -83,15 +83,26 @@ function buildCatalogStructured(models, type, compact) {
83
83
  };
84
84
  }
85
85
 
86
+ // One row per model — every identifier, nothing else. ~90 bytes/model, so the
87
+ // whole 400+ model catalog fits in a payload an agent can actually read.
88
+ const identifierRow = (m) => ({
89
+ identifier: m.identifier,
90
+ name: m.name,
91
+ types: m.types,
92
+ credit: m.credit,
93
+ ...(m.recommended ? { recommended: true } : {}),
94
+ ...(m.new_model ? { new_model: true } : {}),
95
+ });
96
+
86
97
  function registerModelTools(server, client, options = {}) {
87
98
  const ui = () => appsEnabled(server, options);
88
99
  // ─── list_models ───────────────────────────────────────────
89
100
  server.tool(
90
101
  'list_models',
91
- '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.',
102
+ 'List available AI models on Kolbo. Filter by `type` to narrow to a generation type, and pass `format: "json"` to enumerate the catalog with exact identifiers — `format: "json"` + `type` returns the full raw model documents (every constraint field, for programmatic comparison / cap validation before submitting a generation); `format: "json"` alone returns a compact index of EVERY model and its identifier. Default `format: "text"` returns the human-readable summary. NEVER guess a model identifier: call this tool.',
92
103
  {
93
104
  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.'),
94
- 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.'),
105
+ format: z.enum(['text', 'json']).optional().describe('Output format. "text" (default) returns a human-readable summary with the most-used caps. "json" is the source of truth for identifiers and caps: with `type` it returns the raw model documents from the API (identifier, credit, supported_durations, supported_resolutions, supported_aspect_ratios, max_reference_images, max_visual_dna, max_video_duration, ) for EVERY model of that type; without `type` it returns a compact index of every model in the catalog and its exact identifier. Use it whenever you need an identifier you have not seen listed, or must verify a cap before passing a value that might exceed a model-specific limit.'),
95
106
  display_catalog: z.boolean().optional().describe('Set true when the USER explicitly asked to see/browse the available models — the visual catalog opens expanded. Leave unset for internal lookups (verifying a model name, checking caps before a generation): the catalog stays collapsed to a single row the user can tap to browse.')
96
107
  },
97
108
  async ({ type, format, display_catalog }) => {
@@ -106,14 +117,41 @@ function registerModelTools(server, client, options = {}) {
106
117
  const path = type ? `/v1/models?type=${encodeURIComponent(type)}` : '/v1/models';
107
118
  const result = await client.get(path);
108
119
 
109
- // JSON mode return the raw API documents unchanged. This is the
110
- // authoritative shape; every constraint the agent might need to validate
111
- // a request lives here (durations, reference caps, audio/video min/max,
112
- // resolution multipliers, supports_* flags, prompt-length limits, etc.).
120
+ // ⚠️ Hosts that mount this widget (claude.ai, Claude Code desktop) hand the
121
+ // MODEL `structuredContent` and DROP `content[].text`. So every payload the
122
+ // agent needs has to ride in structuredContent shipping it as text only
123
+ // makes it invisible. That is exactly how `format: "json"` came to return
124
+ // the curated 6-per-group picker instead of the raw documents: v1.53.1
125
+ // (406a51e) flipped `if (ui() && showCatalog)` → `if (ui())` on all three
126
+ // return paths, so the widget payload started shadowing the real answer and
127
+ // the other 43 text_to_video identifiers became undiscoverable by any MCP
128
+ // call. On 2026-08-09 that cost a wrong-model generation (minimax-h3).
129
+ // `extra` (json mode) carries the data as structured fields; without it the
130
+ // full text payload is attached verbatim. The widget ignores both.
131
+ const respond = (text, extra) => (ui()
132
+ ? uiResult(UI.catalog, text, {
133
+ ...buildCatalogStructured(result.models, type, !showCatalog),
134
+ ...(extra || { text }),
135
+ })
136
+ : { content: [{ type: 'text', text }] });
137
+
138
+ // JSON mode — the authoritative shape; every constraint the agent might
139
+ // need to validate a request lives here (durations, reference caps,
140
+ // audio/video min/max, resolution multipliers, supports_* flags,
141
+ // prompt-length limits, etc.).
113
142
  if (format === 'json') {
114
- const text = JSON.stringify({ count: result.count, models: result.models }, null, 2);
115
- if (ui()) return uiResult(UI.catalog, text, buildCatalogStructured(result.models, type, !showCatalog));
116
- return { content: [{ type: 'text', text }] };
143
+ // Raw documents once `type` narrows the set (~49 docs for a video type).
144
+ // Unfiltered that is 400+ documents / hundreds of KB, so return the
145
+ // complete IDENTIFIER INDEX instead: every model stays enumerable and
146
+ // the full caps are one `type` away.
147
+ const payload = type
148
+ ? { count: result.count, models: result.models }
149
+ : {
150
+ count: result.count,
151
+ models: result.models.map(identifierRow),
152
+ note: 'Compact index — every model in the catalog and its exact identifier. Re-call with `type` for the full documents (all caps, credit costs, supported_* fields).',
153
+ };
154
+ return respond(JSON.stringify(payload, null, 2), payload);
117
155
  }
118
156
 
119
157
  // Split into auto-selectable (has summary) and named-only (no summary)
@@ -306,9 +344,9 @@ function registerModelTools(server, client, options = {}) {
306
344
  + ' first_last_frame · elements · lipsync · music_gen · text_to_speech ·\n'
307
345
  + ' text_to_sound · stt · three_d · text\n\n'
308
346
  + '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 }] };
347
+ + 'For EVERY model + its exact identifier, re-call with format: "json" (compact index of the '
348
+ + 'whole catalog). Add `type` to that call for the full raw documents with all caps.';
349
+ return respond(text);
312
350
  }
313
351
 
314
352
  if (withSummary.length > 0) {
@@ -318,9 +356,8 @@ function registerModelTools(server, client, options = {}) {
318
356
  sections.push(`Named-only models (${withoutSummary.length}) — only use if the user explicitly requests by name:\n${withoutSummary.map(formatModel).join('\n')}`);
319
357
  }
320
358
 
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".`;
322
- if (ui()) return uiResult(UI.catalog, text, buildCatalogStructured(result.models, type, !showCatalog));
323
- return { content: [{ type: 'text', text }] };
359
+ const text = `Available ${type} models (${result.count}):\n\n${sections.join('\n\n')}\n\nEvery ${type} model in the catalog is listed above — both sections together are the complete set. Use the "identifier" value as the "model" parameter in generate tools. For the raw documents (programmatic cap validation), re-call with format: "json".`;
360
+ return respond(text);
324
361
  }
325
362
  );
326
363