@kolbo/mcp 1.83.5 → 1.84.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/package.json +3 -2
- package/src/index.js +2 -0
- package/src/toolAnnotations.js +9 -1
- package/src/tools/blender.js +425 -0
- package/src/tools/visual_dna.js +10 -6
package/README.md
CHANGED
|
@@ -236,6 +236,21 @@ Every generation tool also accepts an optional `project_id` arg that routes the
|
|
|
236
236
|
| `analyze_script_for_stock` | AI: turn a script into b-roll search terms (`queries[]`, `mediaType`, `keywords`). |
|
|
237
237
|
| `import_stock_asset` | Copy a stock asset into the media library (CDN copy, stable URL). Free. |
|
|
238
238
|
|
|
239
|
+
**Blender Bridge**
|
|
240
|
+
| Tool | Description |
|
|
241
|
+
|------|-------------|
|
|
242
|
+
| `blender_list_sessions` | List the caller's connected Blender processes before choosing a target session |
|
|
243
|
+
| `blender_get_scene` | Queue a bounded scene summary or full scene inspection |
|
|
244
|
+
| `blender_search_docs` | Inspect local Blender RNA and return relevant official API/manual URLs without fetching them |
|
|
245
|
+
| `blender_capture_viewport` | Capture the active viewport to managed cache and optionally Kolbo media |
|
|
246
|
+
| `blender_apply_operations` | Apply approved structured object, material, world, camera, light, animation, duplication, or deletion operations |
|
|
247
|
+
| `blender_import_media` | Import a Kolbo media item or exact-allowlisted Kolbo-owned HTTPS media/CDN asset using smart GLB/image/video placement |
|
|
248
|
+
| `blender_render` | Render a still or an animation capped by Blender at 250 scene frames and 100,000,000 pixel-frames, to managed output and optionally Kolbo media |
|
|
249
|
+
| `blender_undo` | Undo the most recent Blender change in the selected process |
|
|
250
|
+
| `blender_file_operation` | Perform sensitive new/open/save/save-as operations with in-Blender approval |
|
|
251
|
+
| `blender_execute_python` | Execute explicitly reviewed Python plus a required plain-language purpose with full host authority; approval required unless trusted mode is visibly active |
|
|
252
|
+
| `blender_get_command_status` | Read bounded command status/result/error plus absolute `expires_at`; records and idempotency claims expire after 24 hours, and `awaiting_approval` means stop and wait for the user |
|
|
253
|
+
|
|
239
254
|
**Discovery & Account**
|
|
240
255
|
| Tool | Description |
|
|
241
256
|
|------|-------------|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolbo/mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.84.1",
|
|
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": {
|
|
@@ -11,12 +11,13 @@
|
|
|
11
11
|
"smoke": "node scripts/smoke.js",
|
|
12
12
|
"check-skill-bundle": "node scripts/check-skill-bundle.js",
|
|
13
13
|
"check-parity": "node scripts/check-parity.js",
|
|
14
|
-
"prepublishOnly": "node scripts/smoke.js && node scripts/check-skill-bundle.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-doctrine-parity.js && node scripts/check-submission-contract.js && node scripts/check-install.js",
|
|
14
|
+
"prepublishOnly": "node scripts/smoke.js && node --test test/blender-tools.test.js && node scripts/check-skill-bundle.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-doctrine-parity.js && node scripts/check-submission-contract.js && node scripts/check-install.js",
|
|
15
15
|
"check-model-catalog": "node scripts/check-model-catalog.js",
|
|
16
16
|
"check-widget-fields": "node scripts/check-widget-fields.js",
|
|
17
17
|
"check-widget-render": "node scripts/check-widget-render.js",
|
|
18
18
|
"check-skill-tools": "node scripts/check-skill-tools.js",
|
|
19
19
|
"check-submission-contract": "node scripts/check-submission-contract.js",
|
|
20
|
+
"test:blender": "node --test test/blender-tools.test.js",
|
|
20
21
|
"generate-chatgpt-submission": "node scripts/generate-chatgpt-submission.js",
|
|
21
22
|
"check-install": "node scripts/check-install.js",
|
|
22
23
|
"check-doctrine-parity": "node scripts/check-doctrine-parity.js"
|
package/src/index.js
CHANGED
|
@@ -77,6 +77,7 @@ const { registerVoiceTools } = require('./tools/voices');
|
|
|
77
77
|
const { registerMusicLibraryTools } = require('./tools/music_library');
|
|
78
78
|
const { registerStockLibraryTools } = require('./tools/stock_library');
|
|
79
79
|
const { registerAudioStemTools } = require('./tools/audio_stems');
|
|
80
|
+
const { registerBlenderTools } = require('./tools/blender');
|
|
80
81
|
const { registerApps, attachToolWidgetMeta } = require('./apps');
|
|
81
82
|
const { attachToolAnnotations } = require('./toolAnnotations');
|
|
82
83
|
|
|
@@ -179,6 +180,7 @@ function createServer(opts = {}) {
|
|
|
179
180
|
registerMusicLibraryTools(server, client, toolOptions);
|
|
180
181
|
registerStockLibraryTools(server, client, toolOptions);
|
|
181
182
|
registerAudioStemTools(server, client, toolOptions);
|
|
183
|
+
registerBlenderTools(server, client, toolOptions);
|
|
182
184
|
|
|
183
185
|
// MCP Apps widget resources (ui://kolbo/*). Registering resources is inert
|
|
184
186
|
// for text-only hosts — they never fetch them.
|
package/src/toolAnnotations.js
CHANGED
|
@@ -30,6 +30,11 @@ const READ_ONLY = [
|
|
|
30
30
|
'get_stock_collections', 'get_stock_asset', 'analyze_script_for_stock',
|
|
31
31
|
];
|
|
32
32
|
|
|
33
|
+
const OPEN_WORLD_READ_ONLY = [
|
|
34
|
+
'blender_list_sessions', 'blender_get_scene', 'blender_search_docs',
|
|
35
|
+
'blender_get_command_status',
|
|
36
|
+
];
|
|
37
|
+
|
|
33
38
|
const PRIVATE_WRITE = [
|
|
34
39
|
'media_upload_widget', 'create_upload_ticket', 'upload_media',
|
|
35
40
|
'favorite_media', 'unfavorite_media',
|
|
@@ -86,15 +91,18 @@ const DESTRUCTIVE_WRITE = [
|
|
|
86
91
|
];
|
|
87
92
|
|
|
88
93
|
const OPEN_WORLD_WRITE = [
|
|
89
|
-
'publish_html_artifact', 'create_review_share_link',
|
|
94
|
+
'publish_html_artifact', 'create_review_share_link', 'blender_capture_viewport',
|
|
90
95
|
];
|
|
91
96
|
|
|
92
97
|
const OPEN_WORLD_DESTRUCTIVE = [
|
|
93
98
|
'share_doc', 'revoke_review_share_link',
|
|
99
|
+
'blender_apply_operations', 'blender_import_media', 'blender_render',
|
|
100
|
+
'blender_undo', 'blender_file_operation', 'blender_execute_python',
|
|
94
101
|
];
|
|
95
102
|
|
|
96
103
|
const CONTRACT_GROUPS = [
|
|
97
104
|
[READ_ONLY, { readOnlyHint: true, openWorldHint: false, destructiveHint: false }],
|
|
105
|
+
[OPEN_WORLD_READ_ONLY, { readOnlyHint: true, openWorldHint: true, destructiveHint: false }],
|
|
98
106
|
[PRIVATE_WRITE, { readOnlyHint: false, openWorldHint: false, destructiveHint: false }],
|
|
99
107
|
[DESTRUCTIVE_WRITE, { readOnlyHint: false, openWorldHint: false, destructiveHint: true }],
|
|
100
108
|
[OPEN_WORLD_WRITE, { readOnlyHint: false, openWorldHint: true, destructiveHint: false }],
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { z } = require('zod');
|
|
4
|
+
|
|
5
|
+
const MAX_COMMAND_BYTES = 256 * 1024;
|
|
6
|
+
const MAX_ABSOLUTE_NUMBER = 1_000_000_000;
|
|
7
|
+
const MAX_RENDER_PIXELS = 40_000_000;
|
|
8
|
+
const SAFE_ID = /^[A-Za-z0-9._:-]+$/;
|
|
9
|
+
const SAFE_MODIFIER_TYPE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
10
|
+
const SAFE_PROPERTY_KEY = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
11
|
+
const BLOCKED_PROPERTY_KEYS = new Set(['name', 'rna_type', 'type', 'bl_rna']);
|
|
12
|
+
|
|
13
|
+
// Keep exact parity with kolbo_blender/cache.py::_TRUSTED_MEDIA_HOSTS. This
|
|
14
|
+
// duplicated fail-closed boundary prevents an agent from proposing a URL that
|
|
15
|
+
// the desktop host will (correctly) reject later.
|
|
16
|
+
const TRUSTED_MEDIA_HOSTS = new Set([
|
|
17
|
+
'api.kolbo.ai',
|
|
18
|
+
'cdn.kolbo.ai',
|
|
19
|
+
'media.kolbo.ai',
|
|
20
|
+
'media-dev.kolbo.ai',
|
|
21
|
+
'media-staging.kolbo.ai',
|
|
22
|
+
'kolbo-general-media.fra1.cdn.digitaloceanspaces.com',
|
|
23
|
+
'kolbo-general-media.fra1.digitaloceanspaces.com',
|
|
24
|
+
'kolboai-production.ams3.cdn.digitaloceanspaces.com',
|
|
25
|
+
'kolboai-production.ams3.digitaloceanspaces.com',
|
|
26
|
+
'kolboai-staging.ams3.cdn.digitaloceanspaces.com',
|
|
27
|
+
'kolboai-staging.ams3.digitaloceanspaces.com',
|
|
28
|
+
'kolboai-development.ams3.cdn.digitaloceanspaces.com',
|
|
29
|
+
'kolboai-development.ams3.digitaloceanspaces.com',
|
|
30
|
+
'kolboai-media.ams3.cdn.digitaloceanspaces.com',
|
|
31
|
+
'kolboai-media.ams3.digitaloceanspaces.com',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
function isTrustedMediaUrl(value) {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = new URL(value);
|
|
37
|
+
return parsed.protocol === 'https:'
|
|
38
|
+
&& !parsed.username
|
|
39
|
+
&& !parsed.password
|
|
40
|
+
&& TRUSTED_MEDIA_HOSTS.has(parsed.hostname.replace(/\.$/, '').toLowerCase());
|
|
41
|
+
} catch (_) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const noControls = (max) => z.string().min(1).max(max).refine(
|
|
47
|
+
(value) => value.trim().length > 0 && !/[\x00-\x1f]/.test(value),
|
|
48
|
+
`Must be non-empty and contain no control characters (max ${max}).`,
|
|
49
|
+
);
|
|
50
|
+
const sessionId = z.string().min(1).max(128).regex(SAFE_ID).optional().describe(
|
|
51
|
+
'Target Blender session id from blender_list_sessions. Omit only when exactly one active session exists.'
|
|
52
|
+
);
|
|
53
|
+
const idempotencyKey = z.string()
|
|
54
|
+
.min(1)
|
|
55
|
+
.max(128)
|
|
56
|
+
.regex(/^[A-Za-z0-9._:-]+$/)
|
|
57
|
+
.optional()
|
|
58
|
+
.describe('Optional replay-safe key. Reuse it only when retrying the same command.');
|
|
59
|
+
const name = noControls(128);
|
|
60
|
+
const boundedNumber = z.number().finite().min(-MAX_ABSOLUTE_NUMBER).max(MAX_ABSOLUTE_NUMBER);
|
|
61
|
+
const vector = z.tuple([boundedNumber, boundedNumber, boundedNumber]);
|
|
62
|
+
const colorComponent = z.number().finite().min(0).max(1);
|
|
63
|
+
const color = z.union([
|
|
64
|
+
z.tuple([colorComponent, colorComponent, colorComponent]),
|
|
65
|
+
z.tuple([colorComponent, colorComponent, colorComponent, colorComponent]),
|
|
66
|
+
]);
|
|
67
|
+
const propertyScalar = z.union([
|
|
68
|
+
noControls(256),
|
|
69
|
+
boundedNumber,
|
|
70
|
+
z.boolean(),
|
|
71
|
+
]);
|
|
72
|
+
const properties = z.record(z.union([propertyScalar, z.array(propertyScalar).max(16)]))
|
|
73
|
+
.refine((value) => Object.keys(value).length <= 30, 'A modifier may contain at most 30 properties.')
|
|
74
|
+
.refine(
|
|
75
|
+
(value) => Object.keys(value).every((key) => SAFE_PROPERTY_KEY.test(key) && !BLOCKED_PROPERTY_KEYS.has(key)),
|
|
76
|
+
'Modifier property names must use the editable property allowlist.',
|
|
77
|
+
)
|
|
78
|
+
.refine(
|
|
79
|
+
(value) => Buffer.byteLength(JSON.stringify(value), 'utf8') <= 16 * 1024,
|
|
80
|
+
'Modifier properties must be at most 16 KiB as JSON.',
|
|
81
|
+
);
|
|
82
|
+
const transform = {
|
|
83
|
+
location: vector.optional(),
|
|
84
|
+
rotation_euler: vector.optional(),
|
|
85
|
+
scale: vector.optional(),
|
|
86
|
+
};
|
|
87
|
+
const operation = z.discriminatedUnion('op', [
|
|
88
|
+
z.object({
|
|
89
|
+
op: z.literal('object.create'),
|
|
90
|
+
name: name.optional(),
|
|
91
|
+
type: z.enum(['CUBE', 'SPHERE', 'CYLINDER', 'CONE', 'PLANE', 'EMPTY', 'CAMERA', 'LIGHT']),
|
|
92
|
+
light_type: z.enum(['POINT', 'SUN', 'SPOT', 'AREA']).optional(),
|
|
93
|
+
energy: z.number().finite().min(0).max(MAX_ABSOLUTE_NUMBER).optional(),
|
|
94
|
+
color: color.optional(),
|
|
95
|
+
shadow_soft_size: z.number().finite().min(0).max(MAX_ABSOLUTE_NUMBER).optional(),
|
|
96
|
+
lens: z.number().finite().min(1).max(10_000).optional(),
|
|
97
|
+
collection: name.optional(),
|
|
98
|
+
...transform,
|
|
99
|
+
}).strict(),
|
|
100
|
+
z.object({ op: z.literal('object.transform'), object: name, ...transform }).strict(),
|
|
101
|
+
z.object({ op: z.literal('object.rename'), object: name, new_name: name }).strict(),
|
|
102
|
+
z.object({ op: z.literal('object.delete'), object: name }).strict(),
|
|
103
|
+
z.object({
|
|
104
|
+
op: z.literal('object.duplicate'),
|
|
105
|
+
object: name,
|
|
106
|
+
new_name: name.optional(),
|
|
107
|
+
collection: name.optional(),
|
|
108
|
+
...transform,
|
|
109
|
+
}).strict(),
|
|
110
|
+
z.object({ op: z.literal('object.set_parent'), object: name, parent: name.nullable().optional() }).strict(),
|
|
111
|
+
z.object({ op: z.literal('collection.create'), name, parent: name.nullable().optional() }).strict(),
|
|
112
|
+
z.object({ op: z.literal('collection.delete'), collection: name }).strict(),
|
|
113
|
+
z.object({ op: z.literal('collection.link_object'), collection: name, object: name }).strict(),
|
|
114
|
+
z.object({
|
|
115
|
+
op: z.literal('material.create'),
|
|
116
|
+
name,
|
|
117
|
+
base_color: color.optional(),
|
|
118
|
+
roughness: z.number().min(0).max(1).optional(),
|
|
119
|
+
metallic: z.number().min(0).max(1).optional(),
|
|
120
|
+
alpha: z.number().min(0).max(1).optional(),
|
|
121
|
+
emission_color: color.optional(),
|
|
122
|
+
emission_strength: z.number().finite().min(0).max(MAX_ABSOLUTE_NUMBER).optional(),
|
|
123
|
+
}).strict(),
|
|
124
|
+
z.object({ op: z.literal('material.assign'), object: name, material: name, replace: z.boolean().optional() }).strict(),
|
|
125
|
+
z.object({
|
|
126
|
+
op: z.literal('material.set_principled'),
|
|
127
|
+
material: name,
|
|
128
|
+
base_color: color.optional(),
|
|
129
|
+
roughness: z.number().min(0).max(1).optional(),
|
|
130
|
+
metallic: z.number().min(0).max(1).optional(),
|
|
131
|
+
alpha: z.number().min(0).max(1).optional(),
|
|
132
|
+
emission_color: color.optional(),
|
|
133
|
+
emission_strength: z.number().finite().min(0).max(MAX_ABSOLUTE_NUMBER).optional(),
|
|
134
|
+
}).strict(),
|
|
135
|
+
z.object({ op: z.literal('world.set_color'), color }).strict(),
|
|
136
|
+
z.object({
|
|
137
|
+
op: z.literal('modifier.add'),
|
|
138
|
+
object: name,
|
|
139
|
+
type: z.string().regex(SAFE_MODIFIER_TYPE),
|
|
140
|
+
name: name.optional(),
|
|
141
|
+
properties: properties.optional(),
|
|
142
|
+
}).strict(),
|
|
143
|
+
z.object({ op: z.literal('modifier.configure'), object: name, modifier: name, properties }).strict(),
|
|
144
|
+
z.object({ op: z.literal('modifier.remove'), object: name, modifier: name }).strict(),
|
|
145
|
+
z.object({ op: z.literal('camera.set_active'), object: name }).strict(),
|
|
146
|
+
z.object({
|
|
147
|
+
op: z.literal('animation.keyframe_insert'),
|
|
148
|
+
object: name,
|
|
149
|
+
data_path: name,
|
|
150
|
+
frame: z.number().int().min(-1_048_574).max(1_048_574),
|
|
151
|
+
index: z.number().int().min(-1).max(1024).optional(),
|
|
152
|
+
}).strict(),
|
|
153
|
+
z.object({
|
|
154
|
+
op: z.literal('animation.delete_keyframe'),
|
|
155
|
+
object: name,
|
|
156
|
+
data_path: name,
|
|
157
|
+
frame: z.number().int().min(-1_048_574).max(1_048_574),
|
|
158
|
+
index: z.number().int().min(-1).max(1024).optional(),
|
|
159
|
+
}).strict(),
|
|
160
|
+
]).superRefine((value, ctx) => {
|
|
161
|
+
if (value.op === 'object.transform' && !['location', 'rotation_euler', 'scale'].some((field) => value[field] !== undefined)) {
|
|
162
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'object.transform requires at least one transform field.' });
|
|
163
|
+
}
|
|
164
|
+
if (value.op === 'material.set_principled' && ![
|
|
165
|
+
'base_color', 'roughness', 'metallic', 'alpha', 'emission_color', 'emission_strength',
|
|
166
|
+
].some((field) => value[field] !== undefined)) {
|
|
167
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'material.set_principled requires at least one material property.' });
|
|
168
|
+
}
|
|
169
|
+
if (value.op === 'object.create') {
|
|
170
|
+
if (value.type !== 'LIGHT' && ['light_type', 'energy', 'color', 'shadow_soft_size'].some((field) => value[field] !== undefined)) {
|
|
171
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Light settings require object.create type LIGHT.' });
|
|
172
|
+
}
|
|
173
|
+
if (value.type !== 'CAMERA' && value.lens !== undefined) {
|
|
174
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'lens requires object.create type CAMERA.' });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
function countJsonNodes(value, limit = 2500) {
|
|
180
|
+
let count = 0;
|
|
181
|
+
const visit = (entry) => {
|
|
182
|
+
count += 1;
|
|
183
|
+
if (count > limit || entry === null || typeof entry !== 'object') return;
|
|
184
|
+
if (Array.isArray(entry)) entry.forEach(visit);
|
|
185
|
+
else Object.values(entry).forEach(visit);
|
|
186
|
+
};
|
|
187
|
+
visit(value);
|
|
188
|
+
return count;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const operations = z.array(operation).min(1).max(100)
|
|
192
|
+
.refine(
|
|
193
|
+
(value) => Buffer.byteLength(JSON.stringify({ operations: value }), 'utf8') <= MAX_COMMAND_BYTES,
|
|
194
|
+
'Structured operations payload must be at most 256 KiB as JSON.',
|
|
195
|
+
)
|
|
196
|
+
.refine(
|
|
197
|
+
(value) => countJsonNodes({ operations: value }) <= 2500,
|
|
198
|
+
'Structured operations payload is too complex.',
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
function assertPixelBounds(width, height, context) {
|
|
202
|
+
if (width !== undefined && height !== undefined && width * height > MAX_RENDER_PIXELS) {
|
|
203
|
+
throw new Error(`${context} dimensions may not exceed 40 megapixels.`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function text(value) {
|
|
208
|
+
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function envelope(type, args, payload) {
|
|
212
|
+
return {
|
|
213
|
+
...(args.session_id ? { session_id: args.session_id } : {}),
|
|
214
|
+
command_type: type,
|
|
215
|
+
payload,
|
|
216
|
+
...(args.idempotency_key ? { idempotency_key: args.idempotency_key } : {}),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function command(client, type, args, payload) {
|
|
221
|
+
return client.post('/v1/blender/commands', envelope(type, args, payload)).then(text);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function registerBlenderTools(server, client) {
|
|
225
|
+
server.tool(
|
|
226
|
+
'blender_list_sessions',
|
|
227
|
+
'List only the caller\'s currently registered Blender processes. Use this before any Blender command when more than one Blender window may be connected.',
|
|
228
|
+
{
|
|
229
|
+
page: z.number().int().min(1).default(1),
|
|
230
|
+
page_size: z.number().int().min(1).max(100).default(25),
|
|
231
|
+
},
|
|
232
|
+
async ({ page, page_size }) => text(await client.get(`/v1/blender/sessions?page=${page}&page_size=${page_size}`))
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
server.tool(
|
|
236
|
+
'blender_get_scene',
|
|
237
|
+
'Queue a read-only scene inspection in Blender. Returns a command record; if status is queued or running, use blender_get_command_status. If approval is required, stop and let the user approve in Blender.',
|
|
238
|
+
{
|
|
239
|
+
session_id: sessionId,
|
|
240
|
+
detail: z.enum(['summary', 'full']).default('summary').describe('Summary is compact; full includes bounded object details.'),
|
|
241
|
+
include: z.array(z.enum([
|
|
242
|
+
'objects', 'collections', 'render', 'data',
|
|
243
|
+
])).max(4).optional().describe('Scene sections to include. Omit for the standard summary.'),
|
|
244
|
+
include_hidden: z.boolean().default(false),
|
|
245
|
+
max_objects: z.number().int().min(1).max(500).optional(),
|
|
246
|
+
idempotency_key: idempotencyKey,
|
|
247
|
+
},
|
|
248
|
+
async (args) => command(client, 'scene.get', args, {
|
|
249
|
+
detail: args.detail,
|
|
250
|
+
...(args.include ? { include: args.include } : {}),
|
|
251
|
+
include_hidden: args.include_hidden,
|
|
252
|
+
...(args.max_objects ? { max_objects: args.max_objects } : {}),
|
|
253
|
+
})
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
server.tool(
|
|
257
|
+
'blender_search_docs',
|
|
258
|
+
'Inspect the connected Blender runtime and return relevant official Blender API/manual URLs. The extension does not fetch those documentation pages or execute returned code.',
|
|
259
|
+
{
|
|
260
|
+
session_id: sessionId,
|
|
261
|
+
query: noControls(200).describe('Specific Blender API or manual question.'),
|
|
262
|
+
limit: z.number().int().min(1).max(50).default(5),
|
|
263
|
+
idempotency_key: idempotencyKey,
|
|
264
|
+
},
|
|
265
|
+
async (args) => command(client, 'docs.search', args, {
|
|
266
|
+
query: args.query,
|
|
267
|
+
limit: args.limit,
|
|
268
|
+
})
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
server.tool(
|
|
272
|
+
'blender_capture_viewport',
|
|
273
|
+
'Capture the active Blender viewport to the extension-managed cache and optionally add it to the caller\'s Kolbo media library. This creates a file/media record but does not modify the scene.',
|
|
274
|
+
{
|
|
275
|
+
session_id: sessionId,
|
|
276
|
+
format: z.enum(['png', 'jpeg']).default('png'),
|
|
277
|
+
width: z.number().int().min(16).max(8192).optional(),
|
|
278
|
+
height: z.number().int().min(16).max(8192).optional(),
|
|
279
|
+
upload_to_kolbo: z.boolean().default(true),
|
|
280
|
+
project_id: noControls(128).optional().describe('Optional Kolbo project id for the uploaded capture.'),
|
|
281
|
+
idempotency_key: idempotencyKey,
|
|
282
|
+
},
|
|
283
|
+
async (args) => {
|
|
284
|
+
assertPixelBounds(args.width, args.height, 'Viewport capture');
|
|
285
|
+
return command(client, 'viewport.capture', args, {
|
|
286
|
+
format: args.format,
|
|
287
|
+
...(args.width ? { width: args.width } : {}),
|
|
288
|
+
...(args.height ? { height: args.height } : {}),
|
|
289
|
+
upload_to_kolbo: args.upload_to_kolbo,
|
|
290
|
+
...(args.project_id ? { project_id: args.project_id } : {}),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
server.tool(
|
|
296
|
+
'blender_apply_operations',
|
|
297
|
+
'Apply a bounded list of structured Blender operations. This changes the scene and may delete data; preview the exact operations and obtain user approval in Blender when requested.',
|
|
298
|
+
{
|
|
299
|
+
session_id: sessionId,
|
|
300
|
+
operations: operations.describe(
|
|
301
|
+
'Strict top-level operation objects. Use the exact dotted op and only fields accepted by that operation.'
|
|
302
|
+
),
|
|
303
|
+
idempotency_key: idempotencyKey,
|
|
304
|
+
},
|
|
305
|
+
async (args) => command(client, 'scene.apply_operations', args, { operations: args.operations })
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
server.tool(
|
|
309
|
+
'blender_import_media',
|
|
310
|
+
'Import one Kolbo media item or Kolbo-owned HTTPS media/CDN asset into Blender. URLs are restricted to the extension\'s exact v1 host allowlist and revalidated after redirects. GLB imports as a named collection; images support plane/material/world; video imports to the sequencer.',
|
|
311
|
+
{
|
|
312
|
+
session_id: sessionId,
|
|
313
|
+
media_id: noControls(128).optional(),
|
|
314
|
+
url: z.string().url().max(4096)
|
|
315
|
+
.refine(isTrustedMediaUrl, 'Only Kolbo-owned HTTPS media/CDN URLs are accepted.')
|
|
316
|
+
.optional(),
|
|
317
|
+
kind: z.enum(['model', '3d', 'glb', 'image', 'video']).optional(),
|
|
318
|
+
import_mode: z.enum(['plane', 'active_material', 'world', 'sequencer', 'collection']).optional(),
|
|
319
|
+
name: name.optional(),
|
|
320
|
+
idempotency_key: idempotencyKey,
|
|
321
|
+
},
|
|
322
|
+
async (args) => {
|
|
323
|
+
if (Boolean(args.media_id) === Boolean(args.url)) {
|
|
324
|
+
throw new Error('Provide exactly one of media_id or url.');
|
|
325
|
+
}
|
|
326
|
+
return command(client, 'media.import', args, {
|
|
327
|
+
...(args.media_id ? { media_id: args.media_id } : { url: args.url }),
|
|
328
|
+
...(args.kind ? { kind: args.kind } : {}),
|
|
329
|
+
...(args.import_mode ? { import_mode: args.import_mode } : {}),
|
|
330
|
+
...(args.name ? { name: args.name } : {}),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
server.tool(
|
|
336
|
+
'blender_render',
|
|
337
|
+
'Start a Blender still or animation render. V1 animation renders are host-enforced at 250 scene frames and 100,000,000 pixel-frames. Rendering can consume substantial machine resources and writes managed output files; approval may be required in Blender.',
|
|
338
|
+
{
|
|
339
|
+
session_id: sessionId,
|
|
340
|
+
kind: z.enum(['still', 'animation']).default('still'),
|
|
341
|
+
engine: z.enum(['BLENDER_EEVEE_NEXT', 'CYCLES', 'BLENDER_WORKBENCH']).optional(),
|
|
342
|
+
width: z.number().int().min(16).max(8192).optional(),
|
|
343
|
+
height: z.number().int().min(16).max(8192).optional(),
|
|
344
|
+
percentage: z.number().int().min(1).max(100).optional(),
|
|
345
|
+
frame: z.number().int().min(-1_048_574).max(1_048_574).optional(),
|
|
346
|
+
upload_to_kolbo: z.boolean().default(true),
|
|
347
|
+
project_id: noControls(128).optional(),
|
|
348
|
+
idempotency_key: idempotencyKey,
|
|
349
|
+
},
|
|
350
|
+
async (args) => {
|
|
351
|
+
assertPixelBounds(args.width, args.height, 'Render');
|
|
352
|
+
return command(client, 'render.start', args, {
|
|
353
|
+
kind: args.kind,
|
|
354
|
+
...(args.engine ? { engine: args.engine } : {}),
|
|
355
|
+
...(args.width ? { width: args.width } : {}),
|
|
356
|
+
...(args.height ? { height: args.height } : {}),
|
|
357
|
+
...(args.percentage ? { percentage: args.percentage } : {}),
|
|
358
|
+
...(args.frame !== undefined ? { frame: args.frame } : {}),
|
|
359
|
+
upload_to_kolbo: args.upload_to_kolbo,
|
|
360
|
+
...(args.project_id ? { project_id: args.project_id } : {}),
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
server.tool(
|
|
366
|
+
'blender_undo',
|
|
367
|
+
'Undo the most recent Blender change in the selected session. Undo is itself state-changing and can discard later work, so do not call it speculatively.',
|
|
368
|
+
{
|
|
369
|
+
session_id: sessionId,
|
|
370
|
+
idempotency_key: idempotencyKey,
|
|
371
|
+
},
|
|
372
|
+
async (args) => command(client, 'scene.undo', args, {})
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
server.tool(
|
|
376
|
+
'blender_file_operation',
|
|
377
|
+
'Perform a sensitive Blender file operation. Opening, replacing, or writing an arbitrary path can overwrite data and always requires explicit in-Blender approval unless the user enabled trusted mode for this process.',
|
|
378
|
+
{
|
|
379
|
+
session_id: sessionId,
|
|
380
|
+
operation: z.enum(['new', 'open', 'save', 'save_as']),
|
|
381
|
+
path: noControls(4096).optional(),
|
|
382
|
+
confirm_overwrite: z.boolean().default(false),
|
|
383
|
+
idempotency_key: idempotencyKey,
|
|
384
|
+
},
|
|
385
|
+
async (args) => {
|
|
386
|
+
if (['open', 'save_as'].includes(args.operation) && !args.path) {
|
|
387
|
+
throw new Error(`${args.operation} requires path.`);
|
|
388
|
+
}
|
|
389
|
+
return command(client, 'file.operation', args, {
|
|
390
|
+
operation: args.operation,
|
|
391
|
+
...(args.path ? { path: args.path } : {}),
|
|
392
|
+
confirm_overwrite: args.confirm_overwrite,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
server.tool(
|
|
398
|
+
'blender_execute_python',
|
|
399
|
+
'Execute Python inside Blender with full machine-level authority. The code can read files, access the network, run processes, and alter the scene. Show the exact code to the user first; it always requires in-Blender approval unless trusted mode is visibly active.',
|
|
400
|
+
{
|
|
401
|
+
session_id: sessionId,
|
|
402
|
+
code: z.string().min(1).max(65_536).refine(
|
|
403
|
+
(value) => Buffer.byteLength(value, 'utf8') <= 65_536,
|
|
404
|
+
'Python code must be at most 64 KiB as UTF-8.',
|
|
405
|
+
),
|
|
406
|
+
purpose: noControls(500).describe('Plain-language reason shown with the exact code in Blender approval.'),
|
|
407
|
+
idempotency_key: idempotencyKey,
|
|
408
|
+
},
|
|
409
|
+
async (args) => command(client, 'python.execute', args, {
|
|
410
|
+
code: args.code,
|
|
411
|
+
purpose: args.purpose,
|
|
412
|
+
})
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
server.tool(
|
|
416
|
+
'blender_get_command_status',
|
|
417
|
+
'Read the current state, absolute expires_at, and bounded result/error for one Blender command owned by the caller. Commands and idempotency claims expire after 24 hours. awaiting_approval is not a polling state: stop and wait for the user to approve or deny it in Blender.',
|
|
418
|
+
{
|
|
419
|
+
command_id: z.string().min(1).max(128).regex(SAFE_ID),
|
|
420
|
+
},
|
|
421
|
+
async ({ command_id: id }) => text(await client.get(`/v1/blender/commands/${encodeURIComponent(id)}`))
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
module.exports = { registerBlenderTools, envelope };
|
package/src/tools/visual_dna.js
CHANGED
|
@@ -28,6 +28,7 @@ function registerVisualDnaTools(server, client, options = {}) {
|
|
|
28
28
|
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).'),
|
|
29
29
|
dna_type: z.string().optional().describe('Type: "character", "style", "product", "scene", "environment". Default: "character"'),
|
|
30
30
|
prompt_helper: z.string().optional().describe('Optional description/notes to guide DNA extraction'),
|
|
31
|
+
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.'),
|
|
31
32
|
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.'),
|
|
32
33
|
video: z.string().optional().describe('Optional video source (URL or absolute local path)'),
|
|
33
34
|
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`.'),
|
|
@@ -35,7 +36,8 @@ function registerVisualDnaTools(server, client, options = {}) {
|
|
|
35
36
|
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`.'),
|
|
36
37
|
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.')
|
|
37
38
|
},
|
|
38
|
-
async ({ name, dna_type, prompt_helper, images, video, audio, voice_source, assigned_voice_id, character_sheet_url }) => {
|
|
39
|
+
async ({ name, dna_type, prompt_helper, description, images, video, audio, voice_source, assigned_voice_id, character_sheet_url }) => {
|
|
40
|
+
const helper = prompt_helper !== undefined ? prompt_helper : description;
|
|
39
41
|
if (!name || !name.trim()) {
|
|
40
42
|
throw new Error('name is required');
|
|
41
43
|
}
|
|
@@ -58,7 +60,7 @@ function registerVisualDnaTools(server, client, options = {}) {
|
|
|
58
60
|
const form = new FormData();
|
|
59
61
|
form.append('name', name);
|
|
60
62
|
if (dna_type) form.append('dnaType', dna_type);
|
|
61
|
-
if (
|
|
63
|
+
if (helper) form.append('promptHelper', helper);
|
|
62
64
|
if (character_sheet_url) form.append('characterSheetUrl', character_sheet_url);
|
|
63
65
|
// Omitted stays omitted: the server infers 'clone' from a present audio clip, which is the
|
|
64
66
|
// long-standing behaviour older installs depend on. Only an explicit choice is forwarded.
|
|
@@ -193,6 +195,7 @@ function registerVisualDnaTools(server, client, options = {}) {
|
|
|
193
195
|
name: z.string().optional().describe('New name. Same no-space single-token rule as create_visual_dna — @Name binding stops at the first space.'),
|
|
194
196
|
dna_type: z.string().optional().describe('Type: "character", "style", "product", "scene", "environment". Changing type re-analyzes the profile.'),
|
|
195
197
|
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.'),
|
|
198
|
+
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.'),
|
|
196
199
|
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.'),
|
|
197
200
|
video: z.string().optional().describe('Replacement video source (URL or absolute local path).'),
|
|
198
201
|
audio: z.string().optional().describe('Replacement audio source (URL or absolute local path).'),
|
|
@@ -208,14 +211,15 @@ function registerVisualDnaTools(server, client, options = {}) {
|
|
|
208
211
|
specific_age: z.number().optional().describe('Character attribute (character DNAs).')
|
|
209
212
|
},
|
|
210
213
|
async ({
|
|
211
|
-
visual_dna_id, name, dna_type, prompt_helper, images, video, audio,
|
|
214
|
+
visual_dna_id, name, dna_type, prompt_helper, description, images, video, audio,
|
|
212
215
|
character_sheet_url, remove_character_sheet,
|
|
213
216
|
gender, ethnicity, body_type, hair_color, eye_color, skin_tone, age_range, specific_age
|
|
214
217
|
}) => {
|
|
218
|
+
const helper = prompt_helper !== undefined ? prompt_helper : description;
|
|
215
219
|
const imageList = Array.isArray(images) ? images.filter(Boolean) : [];
|
|
216
220
|
if (imageList.length > 4) throw new Error('Maximum 4 images allowed');
|
|
217
221
|
const hasMedia = imageList.length > 0 || !!video || !!audio;
|
|
218
|
-
const hasMeta = name !== undefined || dna_type !== undefined ||
|
|
222
|
+
const hasMeta = name !== undefined || dna_type !== undefined || helper !== undefined
|
|
219
223
|
|| character_sheet_url !== undefined || remove_character_sheet !== undefined
|
|
220
224
|
|| gender !== undefined || ethnicity !== undefined || body_type !== undefined
|
|
221
225
|
|| hair_color !== undefined || eye_color !== undefined || skin_tone !== undefined
|
|
@@ -240,7 +244,7 @@ function registerVisualDnaTools(server, client, options = {}) {
|
|
|
240
244
|
const body = { ...attrs };
|
|
241
245
|
if (name !== undefined) body.name = name;
|
|
242
246
|
if (dna_type !== undefined) body.dna_type = dna_type;
|
|
243
|
-
if (
|
|
247
|
+
if (helper !== undefined) body.prompt_helper = helper;
|
|
244
248
|
if (character_sheet_url !== undefined) body.character_sheet_url = character_sheet_url;
|
|
245
249
|
if (remove_character_sheet !== undefined) body.remove_character_sheet = remove_character_sheet;
|
|
246
250
|
const result = await client.put(path, body);
|
|
@@ -264,7 +268,7 @@ function registerVisualDnaTools(server, client, options = {}) {
|
|
|
264
268
|
const form = new FormData();
|
|
265
269
|
if (name !== undefined) form.append('name', name);
|
|
266
270
|
if (dna_type) form.append('dnaType', dna_type);
|
|
267
|
-
if (
|
|
271
|
+
if (helper !== undefined) form.append('promptHelper', helper);
|
|
268
272
|
if (character_sheet_url) form.append('characterSheetUrl', character_sheet_url);
|
|
269
273
|
if (remove_character_sheet !== undefined) {
|
|
270
274
|
form.append('removeCharacterSheet', remove_character_sheet ? 'true' : 'false');
|