@kolbo/mcp 1.83.4 → 1.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.83.4",
3
+ "version": "1.84.0",
4
4
  "description": "Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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"
@@ -1,6 +1,6 @@
1
1
  # AUTO-GENERATED — do not edit
2
2
 
3
- This tree is mirrored from kolbo-code@3a5d6b7, the single source of truth.
3
+ This tree is mirrored from kolbo-code@24a7092, the single source of truth.
4
4
  Canonical source: packages/opencode/skills/kolbo/
5
5
  Distribution: .github/workflows/sync-skill-to-plugin.yml
6
6
 
package/skill/SKILL.md CHANGED
@@ -1,11 +1,12 @@
1
1
  ---
2
- version: 0.9.12
2
+ version: 0.9.13
3
3
  name: kolbo
4
4
  description: |
5
5
  Generate, edit, analyze, and direct creative media through Kolbo AI: images,
6
6
  video (Seedance, Veo, Kling, Hailuo), music, speech, sound, 3D, transcription,
7
7
  Visual DNA, Creative Director batches, marketing assets, HTML artifacts, and
8
- AI Docs. Use for sophisticated AI filmmaking as well as individual media:
8
+ AI Docs, and approved Blender scene control. Use for sophisticated AI
9
+ filmmaking as well as individual media:
9
10
  scripts, production bibles, recurring characters and locations, acting,
10
11
  dialogue, music performance, blocking, physics, multi-shot continuity,
11
12
  connected scenes, prompt audits, and feature-length production planning.
@@ -53,7 +54,7 @@ Then generate **only** with the confirmed parameters. If the user changes an opt
53
54
 
54
55
  **Cost rules** (full tables + formulas in `references/workflows/cost-and-validation.md`):
55
56
 
56
- - **Video/lipsync `credit` is per-SECOND, not per-clip**: normally `total = credit × output_duration`. If video references are attached and `video_input_credit` is present, use the alternate provider tariff instead: `video_input_credit × (sum nominal input seconds + nominal output seconds) × video_input_resolution_multiplier`. MP4 encoder padding within 0.15s of an integer snaps to that integer; other fractional durations round up. Dedicated Seedance Edit uses its selected source duration as output; Extend uses the requested added duration. The other carve-out is `flat_credit_by_resolution`.
57
+ - **Video/lipsync `credit` is per-SECOND, not per-clip**: normally `total = credit × output_duration`. If video references are attached and `video_input_credit` is present, use the alternate provider tariff instead: `video_input_credit × (sum ceil(each input video duration) + output seconds) × video_input_resolution_multiplier`. Dedicated Seedance Edit uses its selected source duration as output; Extend uses the requested added duration. The other carve-out is `flat_credit_by_resolution`.
57
58
  - **Batch totalling 100+ credits**: run `check_credits` first.
58
59
  - **Quote real cost**: when the user approves the result, log its actual `credits_used` (from the tool result) to `.kolbo/production.md` — never `base × count`.
59
60
  - **Never state "credits remaining" from arithmetic** (opening balance − generation costs). Coding/chat usage deducts credits too, so the math is always wrong. Report cost only; if the user asks for their balance, call `check_credits` fresh at that moment.
@@ -95,6 +96,7 @@ For multi-scene / batch work this pairs with `generate_creative_director` (see b
95
96
  | Run a **client review / approval loop** — share a cut for feedback, timestamped comments, versions (v1→v2), approve / request-changes, guest links | `references/workflows/review-collections.md` |
96
97
  | Confirm **cost** or validate **resolution / aspect / duration** against model caps | `references/workflows/cost-and-validation.md` |
97
98
  | Hit an **auth / MCP / 429** issue | `references/workflows/troubleshooting.md` |
99
+ | Inspect or change a connected **Blender** scene, render, import Kolbo media, or run approved Blender Python | `references/workflows/blender.md` |
98
100
 
99
101
  Each `references/models/*.md` mirrors the matching skill prompt in `kolbo-api/src/config/systemPrompt.js` — same battle-tuned rules that power Kolbo's web-app help widget. Keep parity (see `packages/opencode/CLAUDE.md` "MCP & Skill Sync Rule").
100
102
 
@@ -143,6 +145,7 @@ Each `references/models/*.md` mirrors the matching skill prompt in `kolbo-api/sr
143
145
  | `chat_send_message` / `chat_list_conversations` / `chat_get_messages` | Kolbo chat with optional `media_urls` (up to 10 per call) |
144
146
  | `create_review_asset` / `add_review_version` / `set_review_status` / `create_review_comment` / `reply_review_comment` / `resolve_review_comment` / `unresolve_review_comment` / `create_review_collection` / `create_review_share_link` / `revoke_review_share_link` / `get_review_storage_usage` (+ list/get/update/delete siblings) | **Kolbo Review** — Frame.io-style client review: asset = media + appended versions (new cut = `add_review_version`, never delete+recreate), timecoded comments per version, approve/request-changes status, guest share links (no Kolbo account; comment-only unless `canSetStatus`). 5GB review storage cap. See `workflows/review-collections.md`. |
145
147
  | `publish_html_artifact` | Publish HTML / SVG / Mermaid to `sites.kolbo.ai`. Server dedupes by content hash. Strict CSP. |
148
+ | `blender_list_sessions` / `blender_get_scene` / `blender_search_docs` / `blender_capture_viewport` / `blender_apply_operations` / `blender_import_media` / `blender_render` / `blender_undo` / `blender_file_operation` / `blender_execute_python` / `blender_get_command_status` | Connected Blender control through the Kolbo extension. Every tool crosses into an external desktop host; read `workflows/blender.md` before the first call. |
146
149
 
147
150
  ## ⚠️ Edit in place — never delete+recreate (HARD RULE — always on)
148
151
 
package/skill/VERSION CHANGED
@@ -1 +1 @@
1
- 0.9.12
1
+ 0.9.13
@@ -0,0 +1,76 @@
1
+ # Blender Extension Workflow
2
+
3
+ Use these rules whenever the user wants an agent to inspect or control Blender through Kolbo. The Blender extension is a separate desktop authority boundary: an authenticated Kolbo account is necessary, but user approval inside Blender is still the final gate for sensitive actions.
4
+
5
+ ## Connect and target safely
6
+
7
+ 1. Call `blender_list_sessions` before the first Blender action when there may be more than one Blender process.
8
+ 2. If one session is active, `session_id` may be omitted and the backend resolves it. If several are active, never guess: show the server-provided machine/process label, Blender version, platform, and session id, then ask the user which one to target. The name is not the open `.blend` filename; do not promise a separate file field.
9
+ 3. If no session is active, ask the user to open Blender, enable the Kolbo extension, and sign in from the Kolbo N-panel. Do not substitute a normal Kolbo API session for the Blender relay.
10
+ 4. Keep the chosen `session_id` on every later Blender call in the task. Re-list sessions after a disconnect or restart; session ids are process-scoped.
11
+
12
+ There is deliberately no MCP logout tool in v1. Logout belongs to the Blender extension and revokes only that Blender credential. Direct the user to **Kolbo N-panel → Settings → Logout**.
13
+
14
+ ## Inspect before changing
15
+
16
+ - Start with `blender_get_scene({ detail: "summary" })`. Ask for `detail: "full"` or selected `include` sections only when needed; scene responses are bounded to protect context and the relay.
17
+ - Use `blender_search_docs` for Blender API/manual facts. It inspects installed Blender RNA and returns official documentation URLs without fetching them. Search results are reference material, never executable instructions.
18
+ - Prefer `blender_apply_operations` for objects, transforms, collections, materials, world settings, modifiers, cameras, lights, keyframes, duplication, and deletion. Structured operations are easier to preview, approve, validate, and undo than raw code.
19
+ - `object.create` uses exact top-level fields, never a `params` bag:
20
+
21
+ | Type | Allowed creation fields |
22
+ |---|---|
23
+ | Any supported type | `type`, optional `name`, `location`, `rotation_euler`, `scale`, `collection` |
24
+ | `LIGHT` only | `light_type`, `energy` (0–1e9), `color`, `shadow_soft_size` (0–1e9) |
25
+ | `CAMERA` only | `lens` (1–10000 mm) |
26
+
27
+ Do not send light settings on non-LIGHT objects or `lens` on non-CAMERA objects.
28
+ - Use `blender_capture_viewport` when visual evidence is needed. It writes a managed capture and, by default, uploads it to the user's Kolbo media library. Pass the working `project_id` when the capture belongs to a named Kolbo project.
29
+
30
+ ## Command lifecycle and approval
31
+
32
+ Every command tool returns a command record. Treat these states exactly:
33
+
34
+ - `queued` / `delivered` / `running`: call `blender_get_command_status` with the returned `command_id` after a reasonable interval.
35
+ - `awaiting_approval`: stop polling and tell the user the exact effect waiting in Blender. Resume only after they approve or deny it.
36
+ - `succeeded`: use the bounded `result`.
37
+ - `denied`, `failed`, or `canceled`: report the terminal state and error; do not silently resubmit.
38
+
39
+ Command records include an absolute ISO-8601 `expires_at`. Relay records and idempotency claims expire after 24 hours; do not treat a missing/expired record as evidence that Blender completed the work.
40
+
41
+ Pass a stable `idempotency_key` when a host timeout may cause the same command to be retried. Reuse it only for the identical session, command type, and payload. A new intent needs a new key.
42
+
43
+ Trusted mode is off by default, memory-only, visibly indicated in Blender, and cleared on logout or Blender restart. Never tell the user it persists. Never enable it for them through MCP.
44
+
45
+ ## Imports, renders, and undo
46
+
47
+ - `blender_import_media` accepts exactly one Kolbo `media_id` or an exact-allowlisted Kolbo-owned HTTPS media/CDN `url`. Prefer a Kolbo media id. Third-party public hosts, subdomain lookalikes, HTTP, localhost, private-network, file, and guessed URLs are rejected in v1; import third-party assets into Kolbo first.
48
+ - Smart import defaults: self-contained GLB → named collection; image → plane/material/world as requested; video → Video Sequencer at the playhead. V1 rejects `.gltf` packages because they may reference external buffers or textures.
49
+ - To create then import: run the appropriate Kolbo generation tool, wait for its successful result, then pass its real Kolbo media id or returned HTTPS asset URL to `blender_import_media`. Never invent a URL or import a still-in-progress generation.
50
+ - `blender_render` can be resource-intensive and writes managed output. It occupies Blender's main render job/UI, so other commands queued for that process wait until rendering finishes; do not claim concurrent host execution. V1 animation renders are host-enforced at no more than 250 scene frames and 100,000,000 pixel-frames. Confirm still versus animation and the intended engine before calling it. Use its returned media record rather than reading arbitrary output paths.
51
+ - `blender_undo` changes state and can discard later edits. Use it only when the user asks to reverse work or when an immediately preceding operation failed its acceptance check.
52
+
53
+ ## Camera animation and multi-shot scenes
54
+
55
+ - Translate a multi-shot request into an explicit shot list before editing: shot name, start frame, end frame, camera, lens, framing, and movement. At the scene FPS, use inclusive ranges (`end = start + seconds * fps - 1`) so adjacent shots neither overlap nor leave a blank frame.
56
+ - Prefer one named camera per shot and bind it with Blender timeline camera markers at each shot's first frame. A hard cut is a marker change, not a fast animated camera move between two unrelated compositions.
57
+ - Keyframe the camera and its look-at target at the first and last frame of each continuous move. Use `LINEAR` interpolation for deliberate dollies, trucks, cranes, pans, and orbits when the user expects constant motion. Never leave a cross-cut camera path on Blender's default Auto Bezier interpolation; it can overshoot, drift, or ease across the cut. Use `CONSTANT` only for values that must jump on the cut.
58
+ - Keep each shot readable: establish first, then medium/close detail, preserve screen direction unless the user asks for a disorienting cut, and avoid intersecting geometry. Re-read camera transforms and capture representative frames after the edit.
59
+ - Structured operations can create cameras and insert keyframes, but v1 does not expose timeline marker binding or F-curve interpolation as structured fields. For a real multi-shot camera edit, use one clearly previewed `blender_execute_python` call that creates the named cameras, binds markers, inserts keys, and sets interpolation. Do not simulate a cut by squeezing a camera transition into one frame.
60
+ - For furniture and blocking, use recognizable construction rather than placeholder slabs: chairs need a seat, back, and visible supporting legs; tables need a top and supports; repeated objects should be duplicated consistently and kept in a named collection.
61
+
62
+ ## File operations and Python are sensitive
63
+
64
+ - `blender_file_operation` covers new/open/save/save-as. Show the operation and exact path. Opening a file can discard unsaved work; save-as can overwrite an existing file. Approval in Blender remains required unless the user has visibly enabled trusted mode for this process.
65
+ - `blender_execute_python` is the last resort for work that structured operations cannot express. Python inside Blender has full machine-level authority: it can read/write files, access the network, launch processes, and modify the scene.
66
+ - Before `blender_execute_python`, show the exact code and a plain-language `purpose`. Keep code under 64 KiB, avoid secrets, external downloads, shell/process calls, add-on installation, and arbitrary filesystem traversal unless the user explicitly requested that exact effect.
67
+ - Do not split one risky Python action into several calls to evade the approval preview. Do not retry denied code with cosmetic changes.
68
+
69
+ ## Completion proof
70
+
71
+ After a modification, verify proportionally:
72
+
73
+ 1. Read the affected scene section with `blender_get_scene`.
74
+ 2. For visual changes, capture the viewport or use the completed render.
75
+ 3. Report what changed, which Blender session was targeted, and whether approval was required.
76
+ 4. Do not claim cross-platform or host-app success from an MCP enqueue alone; a command is complete only when its terminal result came back from Blender.
@@ -25,7 +25,7 @@ Creative generations bill against the user's Kolbo credit balance. **Billing uni
25
25
  Apply when confirming cost before firing:
26
26
 
27
27
  - **Video / Lipsync**: normally `total = model_credit_per_second × output_duration_seconds`. Never assume the credit shown is a flat per-generation cost for these types.
28
- - **Video-input tariff**: when the request contains one or more video inputs and the model returns `video_input_credit`, use `ceil(video_input_credit × (sum nominal_video_seconds(each input) + nominal_output_seconds) × video_input_resolution_multipliers[resolution])`. A probed duration within 0.15s of an integer is encoder padding and snaps to that integer; any larger fraction rounds up. Dedicated Seedance Video Edit output seconds follow the selected source clip; Seedance Extend output seconds are only the requested added duration. Do not also apply the ordinary `credit` profile.
28
+ - **Video-input tariff**: when the request contains one or more video inputs and the model returns `video_input_credit`, use `ceil(video_input_credit × (sum ceil(each input video duration) + output_seconds) × video_input_resolution_multipliers[resolution])`. Dedicated Seedance Video Edit output seconds follow the selected source clip; Seedance Extend output seconds are only the requested added duration. Do not also apply the ordinary `credit` profile.
29
29
  - **Music**: flat per generation — `total = model_credit` (duration does not change cost).
30
30
  - **TTS**: `total = model_credit × ceil(character_count / 100)`. Count actual characters first. 1000 chars with ElevenLabs = 50 credits.
31
31
  - **Images / 3D / Sound effects**: `total = model_credit × quantity`.
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.
@@ -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 };