@kolbo/mcp 1.61.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.61.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/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,
@@ -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