@kolbo/mcp 1.86.0 → 1.86.3
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 +2 -1
- package/skill/SKILL.md +435 -434
- package/skill/references/models/3d.md +51 -0
- package/skill/references/models/seedance.md +32 -4
- package/skill/references/workflows/cost-and-validation.md +1 -1
- package/src/apps/index.js +750 -737
- package/src/apps/theme.js +13 -0
- package/src/apps/widgets/generation.js +16 -11
- package/src/apps/widgets/mediaGrid.js +14 -1
- package/src/index.js +6 -1
- package/src/toolAnnotations.js +1 -1
- package/src/tools/_shared.js +183 -32
- package/src/tools/artifacts.js +5 -2
- package/src/tools/generate.js +141 -36
- package/src/tools/media.js +2 -2
- package/src/tools/projects.js +15 -0
- package/src/tools/visual_dna.js +459 -456
- package/src/tools/voices.js +1 -1
package/src/tools/visual_dna.js
CHANGED
|
@@ -1,456 +1,459 @@
|
|
|
1
|
-
/* ⛔ BACKWARD COMPATIBILITY: Tool names and arg names below are a PUBLIC
|
|
2
|
-
* CONTRACT. Never rename, remove, or break an existing tool/arg — old cached
|
|
3
|
-
* `npx @kolbo/mcp` installs in the wild will break silently. Add new tools or
|
|
4
|
-
* new OPTIONAL args only. Full rules: ../index.js top-of-file and CLAUDE.md. */
|
|
5
|
-
|
|
6
|
-
const { z } = require('zod');
|
|
7
|
-
const FormData = require('form-data');
|
|
8
|
-
const { resolveToBuffer: sharedResolveToBuffer, VISUAL_DNA_MAX_BYTES, projectScopeReadField, compactList } = require('./_shared');
|
|
9
|
-
const { UI, uiResult, listResult } = require('../apps');
|
|
10
|
-
|
|
11
|
-
// Reference sheets are a blocking multi-panel render; the 120s client default
|
|
12
|
-
// aborted them mid-flight while the server finished and charged anyway.
|
|
13
|
-
const CHARACTER_SHEET_TIMEOUT_MS = Number(process.env.KOLBO_CHARACTER_SHEET_TIMEOUT_MS) || 600000;
|
|
14
|
-
|
|
15
|
-
// Visual DNA caps reference media at 25MB per file (stricter than the
|
|
16
|
-
// default _shared.resolveToBuffer cap — DNA profiles only need enough
|
|
17
|
-
// source signal to extract features, not full-quality media).
|
|
18
|
-
function resolveToBuffer(source, kind) {
|
|
19
|
-
return sharedResolveToBuffer(source, kind, {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (imageList.length
|
|
50
|
-
throw new Error('
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
form.append('
|
|
75
|
-
}
|
|
76
|
-
if (
|
|
77
|
-
form.append('
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
+ '
|
|
99
|
-
+ '
|
|
100
|
-
+ '
|
|
101
|
-
+ '
|
|
102
|
-
+ '
|
|
103
|
-
+ '
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
||
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
...(
|
|
236
|
-
...(
|
|
237
|
-
...(
|
|
238
|
-
...(
|
|
239
|
-
...(
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if (
|
|
249
|
-
if (
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (
|
|
273
|
-
if (
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
form.append('
|
|
282
|
-
}
|
|
283
|
-
if (
|
|
284
|
-
form.append('
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
|
|
1
|
+
/* ⛔ BACKWARD COMPATIBILITY: Tool names and arg names below are a PUBLIC
|
|
2
|
+
* CONTRACT. Never rename, remove, or break an existing tool/arg — old cached
|
|
3
|
+
* `npx @kolbo/mcp` installs in the wild will break silently. Add new tools or
|
|
4
|
+
* new OPTIONAL args only. Full rules: ../index.js top-of-file and CLAUDE.md. */
|
|
5
|
+
|
|
6
|
+
const { z } = require('zod');
|
|
7
|
+
const FormData = require('form-data');
|
|
8
|
+
const { resolveToBuffer: sharedResolveToBuffer, VISUAL_DNA_MAX_BYTES, projectScopeReadField, compactList } = require('./_shared');
|
|
9
|
+
const { UI, uiResult, listResult } = require('../apps');
|
|
10
|
+
|
|
11
|
+
// Reference sheets are a blocking multi-panel render; the 120s client default
|
|
12
|
+
// aborted them mid-flight while the server finished and charged anyway.
|
|
13
|
+
const CHARACTER_SHEET_TIMEOUT_MS = Number(process.env.KOLBO_CHARACTER_SHEET_TIMEOUT_MS) || 600000;
|
|
14
|
+
|
|
15
|
+
// Visual DNA caps reference media at 25MB per file (stricter than the
|
|
16
|
+
// default _shared.resolveToBuffer cap — DNA profiles only need enough
|
|
17
|
+
// source signal to extract features, not full-quality media).
|
|
18
|
+
function resolveToBuffer(source, kind, options = {}) {
|
|
19
|
+
return sharedResolveToBuffer(source, kind, {
|
|
20
|
+
maxBytes: VISUAL_DNA_MAX_BYTES,
|
|
21
|
+
allowLocalFiles: !options.remote,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function registerVisualDnaTools(server, client, options = {}) {
|
|
26
|
+
// ─── create_visual_dna ─────────────────────────────────────
|
|
27
|
+
server.tool(
|
|
28
|
+
'create_visual_dna',
|
|
29
|
+
'Create a Visual DNA profile from reference media. Each item in images/video/audio can be a public URL or an absolute local file path. Max 4 images, 1 video, 1 audio. Files capped at 25MB each. Those stills are ALL packed into later generations (every image slot the model has, or a white grid if only one leftover slot remains) — so they must share one identity and one vibe. Character DNA: only that person (anonymous crowd OK, no second hero). Environment/location DNA: the place only — empty or anonymous crowd, NEVER a main character or recognizable hero face. Product: only that product. Style: one art direction. Separate states (day/night, clean/bloody) = separate DNAs. For EVERY DNA type, a reference sheet dramatically improves consistency (character turnaround / product details / location angles / style board) — offer to generate one with `generate_character_sheet` (matching `sheet_type`) first, then pass its URL as `character_sheet_url` here (see that tool).',
|
|
30
|
+
{
|
|
31
|
+
name: z.string().describe('Name of the Visual DNA profile. **Pick a short, lowercase, no-space single token** (e.g. `maya`, `tokyo_neon`, `brand_red`, `esther_model`) — never names with spaces (`Sarah Johnson` ❌). The user/LLM types this as `@<name>` inside generation prompts, and the @ parser stops at the first space, so `@Sarah Johnson` matches only `Sarah` and the binding silently drops. Multi-word concepts should use underscores or be a single token. Names are case-insensitive on lookup, but **reserved** values rejected on creation: `Image1`, `Image2`, …, `Video1`, …, `Audio1`, … (any-language characters allowed; max 100 chars).'),
|
|
32
|
+
dna_type: z.string().optional().describe('Type: "character", "style", "product", "scene", "environment". Default: "character"'),
|
|
33
|
+
prompt_helper: z.string().optional().describe('Optional description/notes to guide DNA extraction'),
|
|
34
|
+
description: z.string().optional().describe('Alias for `prompt_helper`. Accepted because every tool here REPORTS this field as `description`, so callers round-trip that name back in; without the alias zod stripped it and the notes were silently lost. Ignored when `prompt_helper` is also given.'),
|
|
35
|
+
images: z.array(z.string()).optional().describe('Array of image sources (URLs or absolute local paths). Max 4. All of them can reach the model on later gens — same subject, same vibe only; no extra heroes on character DNAs, no main characters on environment DNAs.'),
|
|
36
|
+
video: z.string().optional().describe('Optional video source (URL or absolute local path)'),
|
|
37
|
+
audio: z.string().optional().describe('Optional audio source (URL or absolute local path) — the character\'s voice, 5-30s of clean speech. Stored on the DNA and used two ways: (1) as REFERENCE AUDIO in video generation — attaching this DNA to an image-to-video generation on a model with audio slots (Seedance 2.x, Wan 3.0) auto-attaches the clip and tells the model it is that character\'s voice; (2) as the source for a real speaking voice, but ONLY when you ask for one — see `voice_source`.'),
|
|
38
|
+
voice_source: z.enum(['none', 'clone', 'assign', 'design']).optional().describe('What to do about a SPEAKING voice. **Pass "none" when the audio is just a reference clip** (the usual case for video work) — the clip is stored and usable as video reference audio, and nothing else happens. "clone" mints an ElevenLabs voice from the uploaded audio, which consumes a voice slot and may EVICT another of the user\'s voices to free one; it also makes the DNA addressable as `dna_<id>` in text-to-speech. "assign" points at an existing voice (pass `assigned_voice_id`). "design" generates a voice from the character\'s look. ⚠️ Omitting this while passing `audio` keeps the legacy behaviour and CLONES — pass "none" explicitly unless the user asked for a voice.'),
|
|
39
|
+
assigned_voice_id: z.string().optional().describe('Voice to attach when voice_source="assign" — a `custom_<id>` from the user\'s clones or a voice_id from `list_voices`.'),
|
|
40
|
+
character_sheet_url: z.string().optional().describe('URL of a reference sheet (from `generate_character_sheet`, any sheet_type) to set as the DNA\'s primary reference. Works for ALL DNA types — character turnaround, product detail sheet, location sheet, or style board — and is the single biggest consistency booster. Omit only when the user declines.')
|
|
41
|
+
},
|
|
42
|
+
async ({ name, dna_type, prompt_helper, description, images, video, audio, voice_source, assigned_voice_id, character_sheet_url }) => {
|
|
43
|
+
const helper = prompt_helper !== undefined ? prompt_helper : description;
|
|
44
|
+
if (!name || !name.trim()) {
|
|
45
|
+
throw new Error('name is required');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const imageList = Array.isArray(images) ? images.filter(Boolean) : [];
|
|
49
|
+
if (imageList.length > 4) {
|
|
50
|
+
throw new Error('Maximum 4 images allowed');
|
|
51
|
+
}
|
|
52
|
+
if (imageList.length === 0 && !video && !audio) {
|
|
53
|
+
throw new Error('At least one media reference (image, video, or audio) is required');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Resolve all sources to buffers in parallel.
|
|
57
|
+
const [imageFiles, videoFile, audioFile] = await Promise.all([
|
|
58
|
+
Promise.all(imageList.map(src => resolveToBuffer(src, 'image', options))),
|
|
59
|
+
video ? resolveToBuffer(video, 'video', options) : Promise.resolve(null),
|
|
60
|
+
audio ? resolveToBuffer(audio, 'audio', options) : Promise.resolve(null)
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const form = new FormData();
|
|
64
|
+
form.append('name', name);
|
|
65
|
+
if (dna_type) form.append('dnaType', dna_type);
|
|
66
|
+
if (helper) form.append('promptHelper', helper);
|
|
67
|
+
if (character_sheet_url) form.append('characterSheetUrl', character_sheet_url);
|
|
68
|
+
// Omitted stays omitted: the server infers 'clone' from a present audio clip, which is the
|
|
69
|
+
// long-standing behaviour older installs depend on. Only an explicit choice is forwarded.
|
|
70
|
+
if (voice_source) form.append('voiceSource', voice_source);
|
|
71
|
+
if (assigned_voice_id) form.append('assignedVoiceId', assigned_voice_id);
|
|
72
|
+
|
|
73
|
+
for (const f of imageFiles) {
|
|
74
|
+
form.append('images', f.buffer, { filename: f.filename, contentType: f.contentType });
|
|
75
|
+
}
|
|
76
|
+
if (videoFile) {
|
|
77
|
+
form.append('videos', videoFile.buffer, { filename: videoFile.filename, contentType: videoFile.contentType });
|
|
78
|
+
}
|
|
79
|
+
if (audioFile) {
|
|
80
|
+
form.append('audio', audioFile.buffer, { filename: audioFile.filename, contentType: audioFile.contentType });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const result = await client.postMultipart('/v1/visual-dna', form);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
content: [{
|
|
87
|
+
type: 'text',
|
|
88
|
+
text: JSON.stringify(result.visual_dna || result, null, 2)
|
|
89
|
+
}]
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// ─── list_visual_dnas ──────────────────────────────────────
|
|
95
|
+
server.tool(
|
|
96
|
+
'list_visual_dnas',
|
|
97
|
+
'List the user\'s OWN Visual DNA profiles. This is almost always what you want — "my characters", '
|
|
98
|
+
+ '"which DNAs do I have", picking a DNA for a generation. Pass `project_id` to also include a shared '
|
|
99
|
+
+ 'project\'s DNAs (teammates\' assets on that project). '
|
|
100
|
+
+ 'Kolbo ALSO ships a large library of ~1000 global cast/preset DNAs — those are NOT returned by default '
|
|
101
|
+
+ 'because dumping them buries the user\'s own handful. Only pass scope="global" (optionally with '
|
|
102
|
+
+ '`collection` and `search`) when the user explicitly wants to BROWSE the preset cast — e.g. "find me a '
|
|
103
|
+
+ 'character", "show me street style models", "I need a location DNA" — and they have not named one of '
|
|
104
|
+
+ 'their own. Use scope="all" only if the user genuinely wants both at once. '
|
|
105
|
+
+ 'The response reports `total` and `_truncated`; when there are more matches than one '
|
|
106
|
+
+ 'page holds, raise `limit` or walk `page` — do not tell the user the extras do not exist.',
|
|
107
|
+
{
|
|
108
|
+
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.'),
|
|
109
|
+
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.'),
|
|
110
|
+
collection: z.string().optional().describe('Filter global presets by collection: cast, influencers, props, locations, styles, street'),
|
|
111
|
+
tags: z.string().optional().describe('Comma-separated tags to filter by (OR logic)'),
|
|
112
|
+
page: z.number().optional().describe('Page number, 1-indexed. Default: 1. Needed to reach the global cast beyond the first page.'),
|
|
113
|
+
limit: z.number().optional().describe('Results per page, max 100. Default: 50'),
|
|
114
|
+
project_id: projectScopeReadField
|
|
115
|
+
},
|
|
116
|
+
async ({ scope, search, collection, tags, page, limit, project_id } = {}) => {
|
|
117
|
+
const params = new URLSearchParams();
|
|
118
|
+
// Default to the user's OWN DNAs. The API defaults to "all", which pulls
|
|
119
|
+
// in ~1000 global cast presets and buries the handful the user actually
|
|
120
|
+
// made. Global is opt-in via scope="global" (browse the preset cast).
|
|
121
|
+
// Shared-project assets still come through project_id, independently.
|
|
122
|
+
const effectiveScope = scope || 'personal';
|
|
123
|
+
if (effectiveScope !== 'all') params.set('scope', effectiveScope);
|
|
124
|
+
if (search) params.set('search', search);
|
|
125
|
+
if (collection) params.set('collection', collection);
|
|
126
|
+
if (tags) params.set('tags', tags);
|
|
127
|
+
// Always paged. Without these the ~1000-item global cast came back whole and
|
|
128
|
+
// was silently cut to the display cap, so nothing past the first screen was
|
|
129
|
+
// reachable through this tool at all.
|
|
130
|
+
params.set('page', String(page && page > 0 ? Math.floor(page) : 1));
|
|
131
|
+
params.set('limit', String(limit && limit > 0 ? Math.min(Math.floor(limit), 100) : 50));
|
|
132
|
+
if (project_id) params.set('project_id', project_id);
|
|
133
|
+
const qs = params.toString();
|
|
134
|
+
const result = await client.get(`/v1/visual-dna${qs ? '?' + qs : ''}`);
|
|
135
|
+
const dnas = result.visual_dnas || [];
|
|
136
|
+
const total = result.total != null ? result.total : (result.count || dnas.length);
|
|
137
|
+
// Full profiles measured 74,310 chars — the embedded analysis/description
|
|
138
|
+
// blobs are large and the model only needs enough to pick an id.
|
|
139
|
+
// `has_voice_reference` rides along because it changes what a DNA DOES in a generation:
|
|
140
|
+
// such a DNA brings its own voice as reference audio on models with audio slots. Without
|
|
141
|
+
// it the model has to fetch each DNA in full just to find out.
|
|
142
|
+
const text = compactList(dnas, {
|
|
143
|
+
fields: ['id', 'name', 'type', 'dna_type', 'folder_id', 'tags', 'thumbnail', 'description', 'sheet_url', 'has_voice_reference'],
|
|
144
|
+
cap: 60,
|
|
145
|
+
total,
|
|
146
|
+
note: 'Narrow with `search`, `tags`, or `collection`, or pass `page`/`limit` for the rest; get_visual_dna returns one in full.',
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Always ship structuredContent. Kolbo Code does NOT advertise MCP Apps, so
|
|
150
|
+
// gating the grid payload on appsEnabled() sent it text only; the host then
|
|
151
|
+
// rebuilt items from the compactList text, whose field names are
|
|
152
|
+
// `filename`/`url` — not the `title`/`thumbnail` the grid renders — so every
|
|
153
|
+
// tile came out black and unlabelled. Same reasoning as listResult().
|
|
154
|
+
return uiResult(UI.mediaGrid, text, {
|
|
155
|
+
widget: 'media-grid',
|
|
156
|
+
title: 'Visual DNA Profiles',
|
|
157
|
+
items: dnas.slice(0, 24).map(d => ({
|
|
158
|
+
id: d.id,
|
|
159
|
+
title: d.name,
|
|
160
|
+
subtitle: (d.dna_type || '') + (Array.isArray(d.tags) && d.tags.length ? ' · ' + d.tags.slice(0, 3).join(', ') : ''),
|
|
161
|
+
thumbnail: d.sheet_url || d.thumbnail_url || d.thumbnail,
|
|
162
|
+
url: d.sheet_url || d.thumbnail_url || d.thumbnail,
|
|
163
|
+
media_type: 'image',
|
|
164
|
+
use_hint: 'Use Visual DNA "{TITLE}" (id: {ID}) in my next generation for character/style consistency.'
|
|
165
|
+
})),
|
|
166
|
+
total,
|
|
167
|
+
has_more: result.has_more || dnas.length > 24
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// ─── get_visual_dna ────────────────────────────────────────
|
|
173
|
+
server.tool(
|
|
174
|
+
'get_visual_dna',
|
|
175
|
+
'Fetch a single Visual DNA profile by ID. Returns the full profile including system_prompt and all reference images. To fetch a teammate\'s Visual DNA that lives in a shared project, pass project_id (you need edit+ on it).',
|
|
176
|
+
{
|
|
177
|
+
visual_dna_id: z.string().describe('The Visual DNA profile ID'),
|
|
178
|
+
project_id: projectScopeReadField
|
|
179
|
+
},
|
|
180
|
+
async ({ visual_dna_id, project_id }) => {
|
|
181
|
+
const suffix = project_id ? `?project_id=${encodeURIComponent(project_id)}` : '';
|
|
182
|
+
const result = await client.get(`/v1/visual-dna/${encodeURIComponent(visual_dna_id)}` + suffix);
|
|
183
|
+
return {
|
|
184
|
+
content: [{
|
|
185
|
+
type: 'text',
|
|
186
|
+
text: JSON.stringify(result.visual_dna || result, null, 2)
|
|
187
|
+
}]
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// ─── update_visual_dna ─────────────────────────────────────
|
|
193
|
+
server.tool(
|
|
194
|
+
'update_visual_dna',
|
|
195
|
+
'Edit an existing Visual DNA in place — name, description, type, character sheet, stills, video, audio, or character attributes. NEVER delete and recreate a DNA to change any of those: the old id is what generations, sessions, and @Name bindings already point at. Providing `images` REPLACES the whole still set (max 4) and re-analyzes the profile. Omit images to keep current stills. Owner only; global presets cannot be edited (import first).',
|
|
196
|
+
{
|
|
197
|
+
visual_dna_id: z.string().describe('Visual DNA id from list_visual_dnas / create_visual_dna.'),
|
|
198
|
+
name: z.string().optional().describe('New name. Same no-space single-token rule as create_visual_dna — @Name binding stops at the first space.'),
|
|
199
|
+
dna_type: z.string().optional().describe('Type: "character", "style", "product", "scene", "environment". Changing type re-analyzes the profile.'),
|
|
200
|
+
prompt_helper: z.string().optional().describe('New description / intent notes. Replaces the old ones and re-synthesizes the DNA analysis when the text actually changes. Pass "" to clear.'),
|
|
201
|
+
description: z.string().optional().describe('Alias for `prompt_helper`. Accepted because every tool here REPORTS this field as `description`, so callers round-trip that name back in; without the alias zod stripped it and the edit silently did nothing. Ignored when `prompt_helper` is also given.'),
|
|
202
|
+
images: z.array(z.string()).optional().describe('Full replacement still set (URLs or absolute local paths). Max 4. Omit to keep current stills. Same purity rules as create: one identity, one vibe.'),
|
|
203
|
+
video: z.string().optional().describe('Replacement video source (URL or absolute local path).'),
|
|
204
|
+
audio: z.string().optional().describe('Replacement audio source (URL or absolute local path).'),
|
|
205
|
+
character_sheet_url: z.string().optional().describe('New reference sheet URL (from generate_character_sheet). Sets the DNA\'s primary sheet without replacing stills.'),
|
|
206
|
+
remove_character_sheet: z.boolean().optional().describe('If true, clears the stored character sheet. Do not combine with character_sheet_url.'),
|
|
207
|
+
gender: z.string().optional().describe('Character attribute (character DNAs).'),
|
|
208
|
+
ethnicity: z.string().optional().describe('Character attribute (character DNAs).'),
|
|
209
|
+
body_type: z.string().optional().describe('Character attribute (character DNAs).'),
|
|
210
|
+
hair_color: z.string().optional().describe('Character attribute (character DNAs).'),
|
|
211
|
+
eye_color: z.string().optional().describe('Character attribute (character DNAs).'),
|
|
212
|
+
skin_tone: z.string().optional().describe('Character attribute (character DNAs).'),
|
|
213
|
+
age_range: z.string().optional().describe('Character attribute (character DNAs).'),
|
|
214
|
+
specific_age: z.number().optional().describe('Character attribute (character DNAs).')
|
|
215
|
+
},
|
|
216
|
+
async ({
|
|
217
|
+
visual_dna_id, name, dna_type, prompt_helper, description, images, video, audio,
|
|
218
|
+
character_sheet_url, remove_character_sheet,
|
|
219
|
+
gender, ethnicity, body_type, hair_color, eye_color, skin_tone, age_range, specific_age
|
|
220
|
+
}) => {
|
|
221
|
+
const helper = prompt_helper !== undefined ? prompt_helper : description;
|
|
222
|
+
const imageList = Array.isArray(images) ? images.filter(Boolean) : [];
|
|
223
|
+
if (imageList.length > 4) throw new Error('Maximum 4 images allowed');
|
|
224
|
+
const hasMedia = imageList.length > 0 || !!video || !!audio;
|
|
225
|
+
const hasMeta = name !== undefined || dna_type !== undefined || helper !== undefined
|
|
226
|
+
|| character_sheet_url !== undefined || remove_character_sheet !== undefined
|
|
227
|
+
|| gender !== undefined || ethnicity !== undefined || body_type !== undefined
|
|
228
|
+
|| hair_color !== undefined || eye_color !== undefined || skin_tone !== undefined
|
|
229
|
+
|| age_range !== undefined || specific_age !== undefined;
|
|
230
|
+
if (!hasMedia && !hasMeta) {
|
|
231
|
+
throw new Error('Provide at least one field to update');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const attrs = {
|
|
235
|
+
...(gender !== undefined ? { gender } : {}),
|
|
236
|
+
...(ethnicity !== undefined ? { ethnicity } : {}),
|
|
237
|
+
...(body_type !== undefined ? { body_type } : {}),
|
|
238
|
+
...(hair_color !== undefined ? { hair_color } : {}),
|
|
239
|
+
...(eye_color !== undefined ? { eye_color } : {}),
|
|
240
|
+
...(skin_tone !== undefined ? { skin_tone } : {}),
|
|
241
|
+
...(age_range !== undefined ? { age_range } : {}),
|
|
242
|
+
...(specific_age !== undefined ? { specific_age } : {})
|
|
243
|
+
};
|
|
244
|
+
const path = `/v1/visual-dna/${encodeURIComponent(visual_dna_id)}`;
|
|
245
|
+
|
|
246
|
+
if (!hasMedia) {
|
|
247
|
+
const body = { ...attrs };
|
|
248
|
+
if (name !== undefined) body.name = name;
|
|
249
|
+
if (dna_type !== undefined) body.dna_type = dna_type;
|
|
250
|
+
if (helper !== undefined) body.prompt_helper = helper;
|
|
251
|
+
if (character_sheet_url !== undefined) body.character_sheet_url = character_sheet_url;
|
|
252
|
+
if (remove_character_sheet !== undefined) body.remove_character_sheet = remove_character_sheet;
|
|
253
|
+
const result = await client.put(path, body);
|
|
254
|
+
return {
|
|
255
|
+
content: [{
|
|
256
|
+
type: 'text',
|
|
257
|
+
text: JSON.stringify({
|
|
258
|
+
visual_dna: result.visual_dna || result,
|
|
259
|
+
_hint: 'DNA updated in place — keep using this same id and the new stored name in @tags.'
|
|
260
|
+
}, null, 2)
|
|
261
|
+
}]
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const [imageFiles, videoFile, audioFile] = await Promise.all([
|
|
266
|
+
Promise.all(imageList.map(src => resolveToBuffer(src, 'image', options))),
|
|
267
|
+
video ? resolveToBuffer(video, 'video', options) : Promise.resolve(null),
|
|
268
|
+
audio ? resolveToBuffer(audio, 'audio', options) : Promise.resolve(null)
|
|
269
|
+
]);
|
|
270
|
+
|
|
271
|
+
const form = new FormData();
|
|
272
|
+
if (name !== undefined) form.append('name', name);
|
|
273
|
+
if (dna_type) form.append('dnaType', dna_type);
|
|
274
|
+
if (helper !== undefined) form.append('promptHelper', helper);
|
|
275
|
+
if (character_sheet_url) form.append('characterSheetUrl', character_sheet_url);
|
|
276
|
+
if (remove_character_sheet !== undefined) {
|
|
277
|
+
form.append('removeCharacterSheet', remove_character_sheet ? 'true' : 'false');
|
|
278
|
+
}
|
|
279
|
+
for (const [k, v] of Object.entries(attrs)) form.append(k, String(v));
|
|
280
|
+
for (const f of imageFiles) {
|
|
281
|
+
form.append('images', f.buffer, { filename: f.filename, contentType: f.contentType });
|
|
282
|
+
}
|
|
283
|
+
if (videoFile) {
|
|
284
|
+
form.append('videos', videoFile.buffer, { filename: videoFile.filename, contentType: videoFile.contentType });
|
|
285
|
+
}
|
|
286
|
+
if (audioFile) {
|
|
287
|
+
form.append('audio', audioFile.buffer, { filename: audioFile.filename, contentType: audioFile.contentType });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const result = await client.putMultipart(path, form);
|
|
291
|
+
return {
|
|
292
|
+
content: [{
|
|
293
|
+
type: 'text',
|
|
294
|
+
text: JSON.stringify({
|
|
295
|
+
visual_dna: result.visual_dna || result,
|
|
296
|
+
_hint: 'DNA updated in place — keep using this same id and the new stored name in @tags.'
|
|
297
|
+
}, null, 2)
|
|
298
|
+
}]
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
// ─── delete_visual_dna ─────────────────────────────────────
|
|
304
|
+
server.tool(
|
|
305
|
+
'delete_visual_dna',
|
|
306
|
+
'Permanently delete a Visual DNA profile. Only the owner can delete. Do NOT use this to rename, restyle, swap stills, or change a description — that is `update_visual_dna`. Confirm with the user before deleting a DNA they did not just create.',
|
|
307
|
+
{
|
|
308
|
+
visual_dna_id: z.string().describe('The Visual DNA profile ID to delete')
|
|
309
|
+
},
|
|
310
|
+
async ({ visual_dna_id }) => {
|
|
311
|
+
const result = await client.delete(`/v1/visual-dna/${encodeURIComponent(visual_dna_id)}`);
|
|
312
|
+
return {
|
|
313
|
+
content: [{
|
|
314
|
+
type: 'text',
|
|
315
|
+
text: JSON.stringify({
|
|
316
|
+
success: true,
|
|
317
|
+
message: result.message || 'Visual DNA deleted'
|
|
318
|
+
}, null, 2)
|
|
319
|
+
}]
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
// ─── generate_character_sheet ──────────────────────────────
|
|
325
|
+
server.tool(
|
|
326
|
+
'generate_character_sheet',
|
|
327
|
+
'STANDARD FIRST STEP OF THE ASSET PASS for any film/ad/scene: inventory the characters, locations and props the script needs, generate a sheet for each, create its Visual DNA from that sheet, confirm the whole set with the user, and only THEN generate video. Sheets for cinematic environments and invented characters run well on `mirage-film-2` (3cr); use `nano-banana-2` (10cr) or `gpt-image-2` (12cr) when reference fidelity or legible text matters. Generate a reference sheet for a Visual DNA from 1+ reference image URLs — the same step the in-app Visual DNA wizard offers, for EVERY DNA type via `sheet_type`: character = multi-angle turnaround, product = angles + branding/material/construction close-ups, environment = location angles + one signature detail (NO main character / recognizable hero face in an environment sheet — anonymous crowd is OK; those stills are packed into every later gen), style = a style board (the same look applied to six varied subjects). Keep every source image the same identity and vibe. The sheet is the single strongest consistency booster for a DNA, and it always preserves the reference\'s original art style (2D stays 2D, photo stays photo). CHARGES CREDITS, so when the user is about to create a DNA, OFFER this first ("want me to generate a reference sheet for stronger consistency? it costs a few credits") and only run it on a yes. Returns `character_sheet_url` — pass it as `character_sheet_url` to `create_visual_dna` with the matching `dna_type`.',
|
|
328
|
+
{
|
|
329
|
+
image_urls: z.array(z.string()).min(1).describe('Reference image URLs of the subject (for characters: front/side/varied angles work best). Use generated-image URLs or upload_media output.'),
|
|
330
|
+
sheet_type: z.enum(['character', 'character_headless', 'character_bible', 'product', 'environment', 'style']).optional().describe('Sheet layout. character = front/back/face turnaround. character_headless = wardrobe/body refs with a headless front panel (use when clothing must change without fighting the face sheet). character_bible = denser production model-sheet (turnaround + faces + wardrobe + color swatches). product / environment / style = matching DNA types. Defaults to character. This IS the Character Sheet / Headless / Bible preset — do not call list_presets for those names.'),
|
|
331
|
+
model: z.string().optional().describe('Image model for the sheet. Default nano-banana-2. Pass gpt-image-2 when the user names it.'),
|
|
332
|
+
resolution: z.enum(['2K', '4K']).optional().describe('Sheet resolution. 2K or 4K only — never 1K. Default 2K. Use 4K for character_bible, high-detail leads, or when the user asks.')
|
|
333
|
+
},
|
|
334
|
+
async ({ image_urls, sheet_type, model, resolution }) => {
|
|
335
|
+
// The endpoint is blocking and a 2K multi-panel sheet routinely runs past the
|
|
336
|
+
// 120s default: the MCP aborted while kolbo-api kept going, finished, and
|
|
337
|
+
// billed — the user saw "Failed" for a sheet they had already paid for.
|
|
338
|
+
const result = await client.post(
|
|
339
|
+
'/v1/visual-dna/character-sheet',
|
|
340
|
+
{
|
|
341
|
+
image_urls,
|
|
342
|
+
...(sheet_type ? { sheet_type } : {}),
|
|
343
|
+
...(model ? { model } : {}),
|
|
344
|
+
...(resolution ? { resolution } : {}),
|
|
345
|
+
},
|
|
346
|
+
{ timeoutMs: CHARACTER_SHEET_TIMEOUT_MS },
|
|
347
|
+
);
|
|
348
|
+
// `urls` is NOT redundant with character_sheet_url. Every generation-card
|
|
349
|
+
// reader keys on `urls` — the MCP's own widget (apps/widgets/generation.js),
|
|
350
|
+
// kolbo-code's kolbo-operation.ts mediaUrls(), and its operation.js urlsOf().
|
|
351
|
+
// Kolbo Code mounts a generation card for ANY tool named `generate_*`, so
|
|
352
|
+
// returning only character_sheet_url gave the card zero URLs and it rendered
|
|
353
|
+
// "No output received / Failed" on top of a sheet that generated fine and was
|
|
354
|
+
// already billed — and "Try Again" then double-charged. `widget`/`phase` mark
|
|
355
|
+
// the payload as a completed generation so the card stops falling back to the
|
|
356
|
+
// stale `phase: "review"` envelope built before the tool ran.
|
|
357
|
+
const urls = result.character_sheet_url ? [result.character_sheet_url] : [];
|
|
358
|
+
return {
|
|
359
|
+
content: [{
|
|
360
|
+
type: 'text',
|
|
361
|
+
text: JSON.stringify({
|
|
362
|
+
character_sheet_url: result.character_sheet_url,
|
|
363
|
+
urls,
|
|
364
|
+
widget: 'generation',
|
|
365
|
+
phase: 'completed',
|
|
366
|
+
kind: 'image',
|
|
367
|
+
credits_used: result.credits_used,
|
|
368
|
+
_hint: 'Show the sheet to the user, then pass character_sheet_url to create_visual_dna (new DNA) or update_visual_dna (existing DNA). Never delete and recreate.'
|
|
369
|
+
}, null, 2)
|
|
370
|
+
}]
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
// ─── Visual DNA folders (organize characters) ──────────────
|
|
376
|
+
// Folders are user-scoped and flat. Only PERSONAL Visual DNAs can live in
|
|
377
|
+
// folders — global/organization presets are rejected by the server.
|
|
378
|
+
|
|
379
|
+
server.tool(
|
|
380
|
+
'list_visual_dna_folders',
|
|
381
|
+
'List the user\'s Visual DNA folders with per-folder item counts. Use to organize large character casts: find the right folder before moving a DNA, or show the user how their characters are grouped. To list the DNAs INSIDE a folder, call `list_visual_dnas` and filter by the `folder_id` field on each profile.',
|
|
382
|
+
{},
|
|
383
|
+
async () => {
|
|
384
|
+
const result = await client.get('/v1/visual-dna/folders');
|
|
385
|
+
const folders = result.folders || [];
|
|
386
|
+
const text = JSON.stringify({ folders, count: result.count || 0 }, null, 2);
|
|
387
|
+
|
|
388
|
+
return listResult(text, {
|
|
389
|
+
widget: 'list',
|
|
390
|
+
title: 'Visual DNA Folders',
|
|
391
|
+
items: folders.map(f => ({
|
|
392
|
+
id: f.id,
|
|
393
|
+
title: f.name,
|
|
394
|
+
meta: f.item_count != null ? (f.item_count + (f.item_count === 1 ? ' DNA' : ' DNAs')) : null,
|
|
395
|
+
use_hint: 'List Visual DNAs in my "{TITLE}" folder (folder_id: {ID}).'
|
|
396
|
+
})),
|
|
397
|
+
total: folders.length
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
server.tool(
|
|
403
|
+
'create_visual_dna_folder',
|
|
404
|
+
'Create a Visual DNA folder for organizing characters (e.g. "Main Cast", "Villains", "Film X Characters"). Folder names are unique per user (409 on duplicates). Then use `move_visual_dna_to_folder` to file DNAs into it.',
|
|
405
|
+
{
|
|
406
|
+
name: z.string().describe('Folder name (max 100 chars, unique per user).'),
|
|
407
|
+
color: z.string().optional().describe('Optional hex color for the folder chip, e.g. "#FF5733".')
|
|
408
|
+
},
|
|
409
|
+
async ({ name, color }) => {
|
|
410
|
+
const body = { name };
|
|
411
|
+
if (color) body.color = color;
|
|
412
|
+
const result = await client.post('/v1/visual-dna/folders', body);
|
|
413
|
+
return { content: [{ type: 'text', text: JSON.stringify(result.folder, null, 2) }] };
|
|
414
|
+
}
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
server.tool(
|
|
418
|
+
'update_visual_dna_folder',
|
|
419
|
+
'Rename and/or recolor a Visual DNA folder.',
|
|
420
|
+
{
|
|
421
|
+
folder_id: z.string().describe('The folder id (from list_visual_dna_folders).'),
|
|
422
|
+
name: z.string().describe('New folder name (required by the server — pass the current name to keep it).'),
|
|
423
|
+
color: z.string().optional().describe('New hex color, e.g. "#00AA00".')
|
|
424
|
+
},
|
|
425
|
+
async ({ folder_id, name, color }) => {
|
|
426
|
+
const body = { name };
|
|
427
|
+
if (color !== undefined) body.color = color;
|
|
428
|
+
const result = await client.put(`/v1/visual-dna/folders/${encodeURIComponent(folder_id)}`, body);
|
|
429
|
+
return { content: [{ type: 'text', text: JSON.stringify(result.folder, null, 2) }] };
|
|
430
|
+
}
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
server.tool(
|
|
434
|
+
'delete_visual_dna_folder',
|
|
435
|
+
'Delete a Visual DNA folder. The DNAs inside are NOT deleted — they move back to the root level (response includes items_moved_to_root). Safe to call without confirmation for empty folders; mention the contents-move when the folder has items.',
|
|
436
|
+
{
|
|
437
|
+
folder_id: z.string().describe('The folder id to delete.')
|
|
438
|
+
},
|
|
439
|
+
async ({ folder_id }) => {
|
|
440
|
+
const result = await client.delete(`/v1/visual-dna/folders/${encodeURIComponent(folder_id)}`);
|
|
441
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
442
|
+
}
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
server.tool(
|
|
446
|
+
'move_visual_dna_to_folder',
|
|
447
|
+
'Move a Visual DNA into a folder, or back to root. Personal DNAs only — global presets must be imported first, and organization DNAs cannot go in personal folders. When creating many characters for a project, create a folder first and file each DNA as you go.',
|
|
448
|
+
{
|
|
449
|
+
visual_dna_id: z.string().describe('The Visual DNA profile id to move.'),
|
|
450
|
+
folder_id: z.string().nullable().describe('Target folder id (from list_visual_dna_folders), or null to move the DNA back to root.')
|
|
451
|
+
},
|
|
452
|
+
async ({ visual_dna_id, folder_id }) => {
|
|
453
|
+
const result = await client.put(`/v1/visual-dna/${encodeURIComponent(visual_dna_id)}/folder`, { folder_id: folder_id ?? null });
|
|
454
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
455
|
+
}
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
module.exports = { registerVisualDnaTools };
|