@kolbo/mcp
Version:
Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code
431 lines (407 loc) • 18.9 kB
JavaScript
'use strict';
const { z } = require('zod');
const MAX_COMMAND_BYTES = 256 * 1024;
const MAX_ABSOLUTE_NUMBER = 1_000_000_000;
const MAX_RENDER_PIXELS = 40_000_000;
const SAFE_ID = /^[A-Za-z0-9._:-]+$/;
const SAFE_MODIFIER_TYPE = /^[A-Z][A-Z0-9_]{0,63}$/;
const SAFE_PROPERTY_KEY = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
const BLOCKED_PROPERTY_KEYS = new Set(['name', 'rna_type', 'type', 'bl_rna']);
// Keep exact parity with kolbo_blender/cache.py::_TRUSTED_MEDIA_HOSTS. This
// duplicated fail-closed boundary prevents an agent from proposing a URL that
// the desktop host will (correctly) reject later.
const TRUSTED_MEDIA_HOSTS = new Set([
'api.kolbo.ai',
'cdn.kolbo.ai',
'media.kolbo.ai',
'media-dev.kolbo.ai',
'media-staging.kolbo.ai',
'kolbo-general-media.fra1.cdn.digitaloceanspaces.com',
'kolbo-general-media.fra1.digitaloceanspaces.com',
'kolboai-production.ams3.cdn.digitaloceanspaces.com',
'kolboai-production.ams3.digitaloceanspaces.com',
'kolboai-staging.ams3.cdn.digitaloceanspaces.com',
'kolboai-staging.ams3.digitaloceanspaces.com',
'kolboai-development.ams3.cdn.digitaloceanspaces.com',
'kolboai-development.ams3.digitaloceanspaces.com',
'kolboai-media.ams3.cdn.digitaloceanspaces.com',
'kolboai-media.ams3.digitaloceanspaces.com',
]);
function isTrustedMediaUrl(value) {
try {
const parsed = new URL(value);
return parsed.protocol === 'https:'
&& !parsed.username
&& !parsed.password
&& TRUSTED_MEDIA_HOSTS.has(parsed.hostname.replace(/\.$/, '').toLowerCase());
} catch (_) {
return false;
}
}
const noControls = (max) => z.string().min(1).max(max).refine(
(value) => value.trim().length > 0 && !/[\x00-\x1f]/.test(value),
`Must be non-empty and contain no control characters (max ${max}).`,
);
const sessionId = z.string().min(1).max(128).regex(SAFE_ID).optional().describe(
'Target Blender session id from blender_list_sessions. Omit only when exactly one active session exists.'
);
const idempotencyKey = z.string()
.min(1)
.max(128)
.regex(/^[A-Za-z0-9._:-]+$/)
.optional()
.describe('Optional replay-safe key. Reuse it only when retrying the same command.');
// Every shared piece below is a FACTORY, not a shared instance. zod-to-json-schema
// de-duplicates repeated zod instances into JSON-pointer `$ref`s, and this tool
// reuses name/vector/color dozens of times inside one discriminated union, so the
// emitted inputSchema carried 54 `$ref`s, draft-07 tuple `items: [...]` arrays and
// `const` discriminators. ChatGPT's app "Scan Tools" rejects all three
// ("Invalid MCP tool schema for tool 'blender_apply_operations'", 2026-09-06),
// which blocks re-scanning the whole Kolbo app. Fresh instances + fixed-length
// arrays + single-value enums keep the schema inside the plain subset every host
// accepts. Validation semantics are unchanged.
const name = () => noControls(128);
const num = () => z.number().finite().min(-MAX_ABSOLUTE_NUMBER).max(MAX_ABSOLUTE_NUMBER);
const vector = () => z.array(num()).length(3);
const color = () => z.array(z.number().finite().min(0).max(1)).min(3).max(4);
const propertyScalar = () => z.union([
noControls(256),
num(),
z.boolean(),
]);
const properties = () => z.record(z.union([propertyScalar(), z.array(propertyScalar()).max(16)]))
.refine((value) => Object.keys(value).length <= 30, 'A modifier may contain at most 30 properties.')
.refine(
(value) => Object.keys(value).every((key) => SAFE_PROPERTY_KEY.test(key) && !BLOCKED_PROPERTY_KEYS.has(key)),
'Modifier property names must use the editable property allowlist.',
)
.refine(
(value) => Buffer.byteLength(JSON.stringify(value), 'utf8') <= 16 * 1024,
'Modifier properties must be at most 16 KiB as JSON.',
);
const transform = () => ({
location: vector().optional(),
rotation_euler: vector().optional(),
scale: vector().optional(),
});
const op = (value) => z.enum([value]);
const operation = z.discriminatedUnion('op', [
z.object({
op: op('object.create'),
name: name().optional(),
type: z.enum(['CUBE', 'SPHERE', 'CYLINDER', 'CONE', 'PLANE', 'EMPTY', 'CAMERA', 'LIGHT']),
light_type: z.enum(['POINT', 'SUN', 'SPOT', 'AREA']).optional(),
energy: z.number().finite().min(0).max(MAX_ABSOLUTE_NUMBER).optional(),
color: color().optional(),
shadow_soft_size: z.number().finite().min(0).max(MAX_ABSOLUTE_NUMBER).optional(),
lens: z.number().finite().min(1).max(10_000).optional(),
collection: name().optional(),
...transform(),
}).strict(),
z.object({ op: op('object.transform'), object: name(), ...transform() }).strict(),
z.object({ op: op('object.rename'), object: name(), new_name: name() }).strict(),
z.object({ op: op('object.delete'), object: name() }).strict(),
z.object({
op: op('object.duplicate'),
object: name(),
new_name: name().optional(),
collection: name().optional(),
...transform(),
}).strict(),
z.object({ op: op('object.set_parent'), object: name(), parent: name().nullable().optional() }).strict(),
z.object({ op: op('collection.create'), name: name(), parent: name().nullable().optional() }).strict(),
z.object({ op: op('collection.delete'), collection: name() }).strict(),
z.object({ op: op('collection.link_object'), collection: name(), object: name() }).strict(),
z.object({
op: op('material.create'), name: name(),
base_color: color().optional(),
roughness: z.number().min(0).max(1).optional(),
metallic: z.number().min(0).max(1).optional(),
alpha: z.number().min(0).max(1).optional(),
emission_color: color().optional(),
emission_strength: z.number().finite().min(0).max(MAX_ABSOLUTE_NUMBER).optional(),
}).strict(),
z.object({ op: op('material.assign'), object: name(), material: name(), replace: z.boolean().optional() }).strict(),
z.object({
op: op('material.set_principled'),
material: name(),
base_color: color().optional(),
roughness: z.number().min(0).max(1).optional(),
metallic: z.number().min(0).max(1).optional(),
alpha: z.number().min(0).max(1).optional(),
emission_color: color().optional(),
emission_strength: z.number().finite().min(0).max(MAX_ABSOLUTE_NUMBER).optional(),
}).strict(),
z.object({ op: op('world.set_color'), color: color() }).strict(),
z.object({
op: op('modifier.add'),
object: name(),
type: z.string().regex(SAFE_MODIFIER_TYPE),
name: name().optional(),
properties: properties().optional(),
}).strict(),
z.object({ op: op('modifier.configure'), object: name(), modifier: name(), properties: properties() }).strict(),
z.object({ op: op('modifier.remove'), object: name(), modifier: name() }).strict(),
z.object({ op: op('camera.set_active'), object: name() }).strict(),
z.object({
op: op('animation.keyframe_insert'),
object: name(),
data_path: name(),
frame: z.number().int().min(-1_048_574).max(1_048_574),
index: z.number().int().min(-1).max(1024).optional(),
}).strict(),
z.object({
op: op('animation.delete_keyframe'),
object: name(),
data_path: name(),
frame: z.number().int().min(-1_048_574).max(1_048_574),
index: z.number().int().min(-1).max(1024).optional(),
}).strict(),
]).superRefine((value, ctx) => {
if (value.op === 'object.transform' && !['location', 'rotation_euler', 'scale'].some((field) => value[field] !== undefined)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'object.transform requires at least one transform field.' });
}
if (value.op === 'material.set_principled' && ![
'base_color', 'roughness', 'metallic', 'alpha', 'emission_color', 'emission_strength',
].some((field) => value[field] !== undefined)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'material.set_principled requires at least one material property.' });
}
if (value.op === 'object.create') {
if (value.type !== 'LIGHT' && ['light_type', 'energy', 'color', 'shadow_soft_size'].some((field) => value[field] !== undefined)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Light settings require object.create type LIGHT.' });
}
if (value.type !== 'CAMERA' && value.lens !== undefined) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'lens requires object.create type CAMERA.' });
}
}
});
function countJsonNodes(value, limit = 2500) {
let count = 0;
const visit = (entry) => {
count += 1;
if (count > limit || entry === null || typeof entry !== 'object') return;
if (Array.isArray(entry)) entry.forEach(visit);
else Object.values(entry).forEach(visit);
};
visit(value);
return count;
}
const operations = z.array(operation).min(1).max(100)
.refine(
(value) => Buffer.byteLength(JSON.stringify({ operations: value }), 'utf8') <= MAX_COMMAND_BYTES,
'Structured operations payload must be at most 256 KiB as JSON.',
)
.refine(
(value) => countJsonNodes({ operations: value }) <= 2500,
'Structured operations payload is too complex.',
);
function assertPixelBounds(width, height, context) {
if (width !== undefined && height !== undefined && width * height > MAX_RENDER_PIXELS) {
throw new Error(`${context} dimensions may not exceed 40 megapixels.`);
}
}
function text(value) {
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
}
function envelope(type, args, payload) {
return {
...(args.session_id ? { session_id: args.session_id } : {}),
command_type: type,
payload,
...(args.idempotency_key ? { idempotency_key: args.idempotency_key } : {}),
};
}
function command(client, type, args, payload) {
return client.post('/v1/blender/commands', envelope(type, args, payload)).then(text);
}
function registerBlenderTools(server, client) {
server.tool(
'blender_list_sessions',
'List only the caller\'s currently registered Blender processes. Use this before any Blender command when more than one Blender window may be connected.',
{
page: z.number().int().min(1).default(1),
page_size: z.number().int().min(1).max(100).default(25),
},
async ({ page, page_size }) => text(await client.get(`/v1/blender/sessions?page=${page}&page_size=${page_size}`))
);
server.tool(
'blender_get_scene',
'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.',
{
session_id: sessionId,
detail: z.enum(['summary', 'full']).default('summary').describe('Summary is compact; full includes bounded object details.'),
include: z.array(z.enum([
'objects', 'collections', 'render', 'data',
])).max(4).optional().describe('Scene sections to include. Omit for the standard summary.'),
include_hidden: z.boolean().default(false),
max_objects: z.number().int().min(1).max(500).optional(),
idempotency_key: idempotencyKey,
},
async (args) => command(client, 'scene.get', args, {
detail: args.detail,
...(args.include ? { include: args.include } : {}),
include_hidden: args.include_hidden,
...(args.max_objects ? { max_objects: args.max_objects } : {}),
})
);
server.tool(
'blender_search_docs',
'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.',
{
session_id: sessionId,
query: noControls(200).describe('Specific Blender API or manual question.'),
limit: z.number().int().min(1).max(50).default(5),
idempotency_key: idempotencyKey,
},
async (args) => command(client, 'docs.search', args, {
query: args.query,
limit: args.limit,
})
);
server.tool(
'blender_capture_viewport',
'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.',
{
session_id: sessionId,
format: z.enum(['png', 'jpeg']).default('png'),
width: z.number().int().min(16).max(8192).optional(),
height: z.number().int().min(16).max(8192).optional(),
upload_to_kolbo: z.boolean().default(true),
project_id: noControls(128).optional().describe('Optional Kolbo project id for the uploaded capture.'),
idempotency_key: idempotencyKey,
},
async (args) => {
assertPixelBounds(args.width, args.height, 'Viewport capture');
return command(client, 'viewport.capture', args, {
format: args.format,
...(args.width ? { width: args.width } : {}),
...(args.height ? { height: args.height } : {}),
upload_to_kolbo: args.upload_to_kolbo,
...(args.project_id ? { project_id: args.project_id } : {}),
});
}
);
server.tool(
'blender_apply_operations',
'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.',
{
session_id: sessionId,
operations: operations.describe(
'Strict top-level operation objects. Use the exact dotted op and only fields accepted by that operation.'
),
idempotency_key: idempotencyKey,
},
async (args) => command(client, 'scene.apply_operations', args, { operations: args.operations })
);
server.tool(
'blender_import_media',
'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.',
{
session_id: sessionId,
media_id: noControls(128).optional(),
url: z.string().url().max(4096)
.refine(isTrustedMediaUrl, 'Only Kolbo-owned HTTPS media/CDN URLs are accepted.')
.optional(),
kind: z.enum(['model', '3d', 'glb', 'image', 'video']).optional(),
import_mode: z.enum(['plane', 'active_material', 'world', 'sequencer', 'collection']).optional(),
name: name().optional(),
idempotency_key: idempotencyKey,
},
async (args) => {
if (Boolean(args.media_id) === Boolean(args.url)) {
throw new Error('Provide exactly one of media_id or url.');
}
return command(client, 'media.import', args, {
...(args.media_id ? { media_id: args.media_id } : { url: args.url }),
...(args.kind ? { kind: args.kind } : {}),
...(args.import_mode ? { import_mode: args.import_mode } : {}),
...(args.name ? { name: args.name } : {}),
});
}
);
server.tool(
'blender_render',
'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.',
{
session_id: sessionId,
kind: z.enum(['still', 'animation']).default('still'),
engine: z.enum(['BLENDER_EEVEE_NEXT', 'CYCLES', 'BLENDER_WORKBENCH']).optional(),
width: z.number().int().min(16).max(8192).optional(),
height: z.number().int().min(16).max(8192).optional(),
percentage: z.number().int().min(1).max(100).optional(),
frame: z.number().int().min(-1_048_574).max(1_048_574).optional(),
upload_to_kolbo: z.boolean().default(true),
project_id: noControls(128).optional(),
idempotency_key: idempotencyKey,
},
async (args) => {
assertPixelBounds(args.width, args.height, 'Render');
return command(client, 'render.start', args, {
kind: args.kind,
...(args.engine ? { engine: args.engine } : {}),
...(args.width ? { width: args.width } : {}),
...(args.height ? { height: args.height } : {}),
...(args.percentage ? { percentage: args.percentage } : {}),
...(args.frame !== undefined ? { frame: args.frame } : {}),
upload_to_kolbo: args.upload_to_kolbo,
...(args.project_id ? { project_id: args.project_id } : {}),
});
}
);
server.tool(
'blender_undo',
'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.',
{
session_id: sessionId,
idempotency_key: idempotencyKey,
},
async (args) => command(client, 'scene.undo', args, {})
);
server.tool(
'blender_file_operation',
'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.',
{
session_id: sessionId,
operation: z.enum(['new', 'open', 'save', 'save_as']),
path: noControls(4096).optional(),
confirm_overwrite: z.boolean().default(false),
idempotency_key: idempotencyKey,
},
async (args) => {
if (['open', 'save_as'].includes(args.operation) && !args.path) {
throw new Error(`${args.operation} requires path.`);
}
return command(client, 'file.operation', args, {
operation: args.operation,
...(args.path ? { path: args.path } : {}),
confirm_overwrite: args.confirm_overwrite,
});
}
);
server.tool(
'blender_execute_python',
'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.',
{
session_id: sessionId,
code: z.string().min(1).max(65_536).refine(
(value) => Buffer.byteLength(value, 'utf8') <= 65_536,
'Python code must be at most 64 KiB as UTF-8.',
),
purpose: noControls(500).describe('Plain-language reason shown with the exact code in Blender approval.'),
idempotency_key: idempotencyKey,
},
async (args) => command(client, 'python.execute', args, {
code: args.code,
purpose: args.purpose,
})
);
server.tool(
'blender_get_command_status',
'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.',
{
command_id: z.string().min(1).max(128).regex(SAFE_ID),
},
async ({ command_id: id }) => text(await client.get(`/v1/blender/commands/${encodeURIComponent(id)}`))
);
}
module.exports = { registerBlenderTools, envelope, isTrustedMediaUrl };