@volter/editor-blender 0.1.6 → 0.1.8

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.
@@ -0,0 +1,406 @@
1
+ /**
2
+ * `<product> blender-mcp` — the blender-mcp server SHAPE (the published server's 28
3
+ * tools, names, parameters, descriptions, result strings and its one prompt)
4
+ * as pure transport onto Blender running in the project's editor tab.
5
+ *
6
+ * No modeling happens in this process. `execute_blender_code`,
7
+ * `get_scene_info`, `get_object_info` and `get_viewport_screenshot` become
8
+ * `blender-*` control commands answered by the tab's worker
9
+ * (`packages/blender/contributions/blender.command.ts`, the engine's own
10
+ * `workspace.command` contribution); the screenshot is the tab
11
+ * photographing its own Model document. The 24 integration tools answer as
12
+ * an add-on with no integrations, exactly as the Python server did.
13
+ */
14
+ import { randomUUID } from 'node:crypto';
15
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { dirname, relative, sep } from 'node:path';
17
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
18
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
19
+ import {
20
+ CallToolRequestSchema,
21
+ GetPromptRequestSchema,
22
+ ListPromptsRequestSchema,
23
+ ListToolsRequestSchema,
24
+ } from '@modelcontextprotocol/sdk/types.js';
25
+ import { EditorClient, type EditorView } from '@volter/editor-sdk';
26
+ import blenderBundle from '@volter/blender-engine/wasm/BUNDLE.json';
27
+ import { projectOutputRootOf } from '@volter/editor-sdk/project/output-roots';
28
+ import blenderDefaults from './blender-mcp-defaults.json';
29
+ import blenderTools from './blender-mcp-tools.json';
30
+
31
+ interface ToolShape {
32
+ name: string;
33
+ description: string;
34
+ inputSchema: Record<string, unknown>;
35
+ }
36
+
37
+ const DISABLED = {
38
+ polyhaven:
39
+ 'PolyHaven integration is disabled. Select it in the sidebar in BlenderMCP, then run it again.',
40
+ sketchfab:
41
+ 'Sketchfab integration is disabled. Select it in the sidebar in BlenderMCP, then run it again.',
42
+ polypizza:
43
+ 'Poly Pizza integration is disabled. Select it in the sidebar in BlenderMCP, then run it again.',
44
+ hyper3d:
45
+ 'Hyper3D Rodin integration is disabled. Select it in the sidebar in BlenderMCP, then run it again.',
46
+ hunyuan:
47
+ 'Hunyuan3D integration is disabled. Select it in the sidebar in BlenderMCP, then run it again.',
48
+ };
49
+
50
+ const STATIC_TOOLS: Record<string, string> = {
51
+ disable_telemetry: 'Data collection is now OFF.',
52
+ get_polyhaven_categories: DISABLED.polyhaven,
53
+ search_polyhaven_assets: DISABLED.polyhaven,
54
+ download_polyhaven_asset: DISABLED.polyhaven,
55
+ set_texture: DISABLED.polyhaven,
56
+ search_sketchfab_models: DISABLED.sketchfab,
57
+ get_sketchfab_model_preview: DISABLED.sketchfab,
58
+ download_sketchfab_model: DISABLED.sketchfab,
59
+ search_polypizza_models: DISABLED.polypizza,
60
+ download_polypizza_model: DISABLED.polypizza,
61
+ generate_hyper3d_model_via_text: DISABLED.hyper3d,
62
+ generate_hyper3d_model_via_images: DISABLED.hyper3d,
63
+ poll_rodin_job_status: DISABLED.hyper3d,
64
+ import_generated_asset: DISABLED.hyper3d,
65
+ generate_hunyuan3d_model: DISABLED.hunyuan,
66
+ poll_hunyuan_job_status: DISABLED.hunyuan,
67
+ import_generated_asset_hunyuan: DISABLED.hunyuan,
68
+ record_trajectory_feedback:
69
+ 'Trajectory feedback skipped (telemetry disabled, no consent, or write failed)',
70
+ };
71
+
72
+ const TOOLS_PROMPT = `When creating 3D content in Blender, always start by checking if integrations are available:
73
+
74
+ 0. Before anything, always check the scene from get_scene_info()
75
+ 1. First use the following tools to verify if the following integrations are enabled:
76
+ - PolyHaven
77
+ - Sketchfab
78
+ - Hyper3D
79
+ - Hunyuan3D
80
+ - Poly Pizza
81
+
82
+ 2. If none of the integrations are enabled, create the objects using Blender's built-in tools with execute_blender_code.
83
+
84
+ 3. When including an object into scene, ALWAYS make sure that the name of the object is meanful.
85
+
86
+ 4. Always check the world_bounding_box for each item so that:
87
+ - Ensure that all objects that should not be clipping are not clipping.
88
+ - Items have right spatial relationship.
89
+
90
+ 5. After giving the tool location/scale/rotation information (via execute_blender_code), verify the new properties by using get_object_info to confirm the changes.
91
+ `;
92
+
93
+ export const BLENDER_MCP_VERSION = '1.2';
94
+
95
+ interface FileEntry {
96
+ path: string;
97
+ size: number;
98
+ mtime: number;
99
+ }
100
+
101
+ /** A path INSIDE the project, POSIX-spelled, or null when it is outside. */
102
+ function projectRelative(project: string, absolute: string): string | null {
103
+ const rel = relative(project, absolute).split(sep).join('/');
104
+ if (rel === '' || rel.startsWith('../')) return null;
105
+ return rel;
106
+ }
107
+
108
+ /**
109
+ * The worker's filesystem is the only place a script's outputs exist; this
110
+ * mirrors them to disk. Roots: the project (a script's `public/models/x.glb`
111
+ * lands in the project) plus any `VGAI_BLENDER_MIRROR_ROOTS` (colon-separated
112
+ * absolute paths — the replay harness names its run directory). Only files
113
+ * whose size or mtime changed since the last mirror are read back, and only
114
+ * files the SESSION owns are listed at all — a project file it merely read
115
+ * belongs to the host and is never written back over it.
116
+ *
117
+ * THIS IS THE DOOR THAT RECORDS, because it is the door a session's bytes
118
+ * enter the project through. MEASURED 2026-09-18: a script's
119
+ * `export_scene.gltf` to `<project>/public/models/lantern.glb` landed here and
120
+ * `.vgai/provenance.json` was never created — the one state
121
+ * `scripts/validate-project-provenance.mjs` calls fatal, and the opposite of
122
+ * the doctrine that a generated artifact enters `public/` through a door that
123
+ * records it atomically. So a file this lands under `public/` is POSTed to the
124
+ * session's `/__editor/blender-output`, which commits the bytes and the ledger
125
+ * rewrite as ONE rollback-safe transaction through the same writer the
126
+ * editor's own bake and acceptance tools use; the record's kind is
127
+ * `project.session.write` and it names the session (see
128
+ * `ProjectProvenanceSessionSchema`).
129
+ *
130
+ * The session process owns the writer and the project's ledger. This
131
+ * transport uses that same session boundary as the other product commands;
132
+ * it never creates a second ledger writer in the MCP process.
133
+ *
134
+ * Everything else the session owns — `.vgai/tmp/*.png`, a `.blend`, a replay
135
+ * harness's run directory outside the project — is mirrored with a plain write
136
+ * and deliberately NOT recorded: the ledger is about what the project SHIPS,
137
+ * and `public/` is what ships.
138
+ */
139
+ class Mirror {
140
+ readonly #seen = new Map<string, string>();
141
+ constructor(
142
+ private readonly project: string,
143
+ private readonly roots: string[],
144
+ /** The command that served this transport, recorded as the write's source. */
145
+ private readonly source: string,
146
+ ) {}
147
+
148
+ async sync(tab: TabSession, callId: string | null): Promise<number> {
149
+ let written = 0;
150
+ for (const root of this.roots) {
151
+ const listing = await tab.command<{
152
+ entries: FileEntry[];
153
+ session?: string;
154
+ revision?: number;
155
+ document?: string;
156
+ }>('blender-list-files', { path: root });
157
+ for (const entry of listing.entries) {
158
+ const stamp = `${entry.size}:${entry.mtime}`;
159
+ if (this.#seen.get(entry.path) === stamp) continue;
160
+ const projectPath = projectRelative(this.project, entry.path);
161
+ const shipped =
162
+ projectPath !== null && projectOutputRootOf(projectPath) === 'public'
163
+ ? projectPath
164
+ : null;
165
+ // ANTI-SHIM: an unrecordable shipped file is REFUSED, not written
166
+ // anyway. Landing it unrecorded is the defect this door exists to
167
+ // close, and inventing a session for it would be worse than both.
168
+ if (shipped !== null && (listing.session === undefined || listing.revision === undefined))
169
+ throw new Error(
170
+ `The Blender session wrote ${shipped} but has not named itself — it has presented no ` +
171
+ 'frame, so there is no session identity or revision to record the file under, and ' +
172
+ 'an unrecorded binary under public/ is what `npm run validate-provenance` calls ' +
173
+ 'fatal. Nothing was written for it.',
174
+ );
175
+ // WE mint the transfer id and WE choose where the bytes land; the page
176
+ // only hands over a body. See `/__editor/blender-file`.
177
+ const transferId = randomUUID();
178
+ await tab.command<{ bytes: number }>('blender-read-file', { path: entry.path, transferId });
179
+ const spooled = await fetch(`${await tab.origin()}/__editor/blender-file?id=${transferId}`);
180
+ if (!spooled.ok) throw new Error(`Transfer of ${entry.path} failed: ${spooled.status}`);
181
+ const bytes = Buffer.from(await spooled.arrayBuffer());
182
+ if (shipped === null) {
183
+ // Staged inputs are listed alongside outputs. Rewriting an unchanged
184
+ // adapter triggers project reload during this very MCP request.
185
+ // Only changed bytes should reach the filesystem watcher.
186
+ try {
187
+ if (readFileSync(entry.path).equals(bytes)) {
188
+ this.#seen.set(entry.path, stamp);
189
+ continue;
190
+ }
191
+ } catch (error) {
192
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
193
+ }
194
+ mkdirSync(dirname(entry.path), { recursive: true });
195
+ writeFileSync(entry.path, bytes);
196
+ } else {
197
+ const query = new URLSearchParams({
198
+ path: shipped,
199
+ session: listing.session as string,
200
+ revision: String(listing.revision as number),
201
+ source: this.source,
202
+ ...(callId === null ? {} : { callId }),
203
+ });
204
+ // WHAT THE BYTES WERE MADE FROM, when the session holds a document:
205
+ // the `.blend` this export came out of. Passed as a repeatable
206
+ // `inputs` param rather than folded into the path, because the
207
+ // server validates each one as a project-relative path of its own.
208
+ if (typeof listing.document === 'string' && listing.document !== '')
209
+ query.append('inputs', listing.document);
210
+ const recorded = await fetch(`${await tab.origin()}/__editor/blender-output?${query}`, {
211
+ method: 'POST',
212
+ body: new Uint8Array(bytes),
213
+ });
214
+ if (!recorded.ok) {
215
+ const detail = await recorded.text().catch(() => '');
216
+ throw new Error(
217
+ `Recording ${shipped} through the session failed: ${recorded.status} ${detail}`,
218
+ );
219
+ }
220
+ }
221
+ this.#seen.set(entry.path, stamp);
222
+ written += 1;
223
+ }
224
+ }
225
+ return written;
226
+ }
227
+ }
228
+
229
+ class TabSession {
230
+ #client: EditorClient | null = null;
231
+ #connecting: Promise<EditorClient> | null = null;
232
+ #origin: string | null = null;
233
+ #port: number | null = null;
234
+ /** A replay starts with a fresh model once; ordinary agent sessions persist. */
235
+ #freshSessionPending = Boolean(process.env['VGAI_BLENDER_FRESH_SESSION']);
236
+ constructor(
237
+ private readonly project: string,
238
+ private readonly ensureEditor: () => Promise<void>,
239
+ ) {}
240
+
241
+ async client(): Promise<EditorClient> {
242
+ if (this.#client) return this.#client;
243
+ if (!this.#connecting) {
244
+ this.#connecting = (async () => {
245
+ await this.ensureEditor();
246
+ const { resolveSession } = await import('@volter/editor-live');
247
+ const session = await resolveSession(this.project);
248
+ this.#port = session.port;
249
+ this.#origin = `http://127.0.0.1:${session.port}`;
250
+ this.#client = new EditorClient({ url: this.#origin });
251
+ return this.#client;
252
+ })().finally(() => {
253
+ this.#connecting = null;
254
+ });
255
+ }
256
+ return this.#connecting;
257
+ }
258
+
259
+ /** This session's HTTP origin, for the doors that are not commands. */
260
+ async origin(): Promise<string> {
261
+ await this.client();
262
+ return this.#origin as string;
263
+ }
264
+
265
+ /** The machine-local port this project's session answers on — the address a
266
+ * provenance record names so a reader can get back to the tab. */
267
+ async port(): Promise<number> {
268
+ await this.client();
269
+ return this.#port as number;
270
+ }
271
+
272
+ /**
273
+ * The Model document open and the session started at this project's path.
274
+ *
275
+ * Asked every call, never remembered: the session lives in the editor tab,
276
+ * so an editor restart ends it while this process keeps running.
277
+ * The editor owns opening the Model document and idempotent startup. A
278
+ * separate status query would put a five-second check before that startup
279
+ * request and introduce a race with an editor restart.
280
+ */
281
+ async started(): Promise<EditorClient> {
282
+ const client = await this.client();
283
+ const fresh = this.#freshSessionPending;
284
+ this.#freshSessionPending = false;
285
+ await client.blender('blender-start', { project: this.project, ...(fresh ? { fresh } : {}) });
286
+ return client;
287
+ }
288
+
289
+ async command<T>(type: string, fields: Record<string, unknown> = {}): Promise<T> {
290
+ const client = await this.started();
291
+ return client.blender<T & object>(type as `blender-${string}`, fields);
292
+ }
293
+ }
294
+
295
+ const text = (value: string) => ({ content: [{ type: 'text' as const, text: value }] });
296
+
297
+ export async function serveBlenderMcp(
298
+ project: string,
299
+ ensureEditor: () => Promise<void> = async () => {},
300
+ command = 'volter-editor',
301
+ ): Promise<void> {
302
+ const runtimeIdentity = `Blender ${blenderBundle.blender} (source ${blenderBundle.source}), compiled to WebAssembly and running headless in the editor tab; three.js takes its photographs. Factory bpy.context.scene.render.engine: '${blenderBundle.factoryEngine}'. Documents are .blend. save_as_mainfile records the actual saved path in bpy.data.filepath; open_mainfile reopens it.`;
303
+ const tools = (blenderTools as ToolShape[]).map((tool) =>
304
+ tool.name === 'execute_blender_code'
305
+ ? { ...tool, description: `${runtimeIdentity}\n${tool.description}` }
306
+ : tool,
307
+ );
308
+ const defaults = blenderDefaults as Record<string, string>;
309
+ const mirror = new Mirror(project, [
310
+ project,
311
+ ...(process.env['VGAI_BLENDER_MIRROR_ROOTS'] ?? '')
312
+ .split(':')
313
+ .filter((root) => root.startsWith('/')),
314
+ ], `${command} blender-mcp`);
315
+ const tab = new TabSession(project, ensureEditor);
316
+ const server = new Server(
317
+ { name: 'BlenderMCP', version: BLENDER_MCP_VERSION },
318
+ {
319
+ capabilities: { tools: {}, prompts: {} },
320
+ instructions: runtimeIdentity,
321
+ },
322
+ );
323
+
324
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
325
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({
326
+ prompts: [{ name: 'asset_creation_strategy', description: '' }],
327
+ }));
328
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
329
+ if (request.params.name !== 'asset_creation_strategy')
330
+ throw new Error(`Unknown prompt: ${request.params.name}`);
331
+ return { messages: [{ role: 'user', content: { type: 'text', text: TOOLS_PROMPT } }] };
332
+ });
333
+
334
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
335
+ const name = request.params.name;
336
+ const args = (request.params.arguments ?? {}) as Record<string, unknown>;
337
+ // The transport's own number for THIS call. A file the script writes is
338
+ // recorded under it, so a ledger entry points back at the exact request
339
+ // that produced the bytes.
340
+ const callId = extra.requestId === undefined ? null : String(extra.requestId);
341
+ if (name in STATIC_TOOLS) return text(STATIC_TOOLS[name]!);
342
+ if (name in defaults) return text(defaults[name]!);
343
+ switch (name) {
344
+ case 'get_addon_status':
345
+ return text(
346
+ JSON.stringify(
347
+ {
348
+ addon_version: BLENDER_MCP_VERSION,
349
+ server_version: BLENDER_MCP_VERSION,
350
+ up_to_date: true,
351
+ },
352
+ null,
353
+ 2,
354
+ ),
355
+ );
356
+ case 'get_scene_info': {
357
+ const { result } = await tab.command<{ result: string }>('blender-scene-info');
358
+ return text(result);
359
+ }
360
+ case 'get_object_info': {
361
+ const { result } = await tab.command<{ result: string }>('blender-object-info', {
362
+ name: String(args['object_name'] ?? ''),
363
+ });
364
+ return text(result);
365
+ }
366
+ case 'execute_blender_code': {
367
+ const { result } = await tab.command<{ result: string }>('blender-execute', {
368
+ code: String(args['code'] ?? ''),
369
+ });
370
+ await mirror.sync(tab, callId);
371
+ return text(result);
372
+ }
373
+ case 'get_viewport_screenshot': {
374
+ const maxSize = typeof args['max_size'] === 'number' ? args['max_size'] : 1000;
375
+ const answer = await tab.command<{
376
+ document: NonNullable<EditorView['document']>;
377
+ view?: { size: number; position?: number[]; target?: number[] };
378
+ error?: string;
379
+ }>('blender-screenshot-view', { maxSize });
380
+ if (!answer.document) throw new Error('Screenshot failed: Blender supplied no document address');
381
+ if (!answer.view)
382
+ throw new Error(answer.error ?? 'Screenshot failed: No 3D viewport found');
383
+ const { view } = answer;
384
+ const camera =
385
+ view.position && view.target
386
+ ? {
387
+ camera: {
388
+ position: { x: view.position[0]!, y: view.position[1]!, z: view.position[2]! },
389
+ target: { x: view.target[0]!, y: view.target[1]!, z: view.target[2]! },
390
+ },
391
+ }
392
+ : { frame: 'document' as const };
393
+ const client = await tab.started();
394
+ const presentation: EditorView = { version: 1, document: answer.document, viewport: camera };
395
+ await client.present(presentation);
396
+ const capture = await client.captureActiveDocument(view.size, presentation);
397
+ return {
398
+ content: [{ type: 'image' as const, data: capture.base64, mimeType: 'image/png' }],
399
+ };
400
+ }
401
+ }
402
+ throw new Error(`Unknown tool: ${name}`);
403
+ });
404
+
405
+ await server.connect(new StdioServerTransport());
406
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/editor-blender",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "AGPL-3.0-only AND GPL-3.0-or-later",
5
- "version": "0.1.6",
5
+ "version": "0.1.8",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
@@ -17,12 +17,15 @@
17
17
  "src",
18
18
  "contributions",
19
19
  "host",
20
- "LICENSE"
20
+ "node",
21
+ "LICENSE",
22
+ "LICENSE-MCP"
21
23
  ],
22
24
  "exports": {
23
25
  "./contributions/*": "./contributions/*",
24
26
  "./looks": "./src/looks.ts",
25
27
  "./layouts": "./src/layouts.tsx",
28
+ "./mcp": "./node/blender-mcp.ts",
26
29
  "./package.json": "./package.json"
27
30
  },
28
31
  "vgai": {
@@ -61,13 +64,15 @@
61
64
  ]
62
65
  },
63
66
  "dependencies": {
64
- "@volter/blender-engine": "0.1.6",
65
- "@volter/editor-threejs": "0.5.63",
67
+ "@modelcontextprotocol/sdk": "1.30.0",
68
+ "@volter/blender-engine": "0.1.7",
69
+ "@volter/editor-threejs": "0.5.65",
66
70
  "zod": "^4.3.6"
67
71
  },
68
72
  "peerDependencies": {
69
- "@volter/editor-sdk": "0.5.63",
70
- "@volter/editor-project": "0.5.63",
73
+ "@volter/editor-sdk": "0.5.65",
74
+ "@volter/editor-live": "0.5.65",
75
+ "@volter/editor-project": "0.5.65",
71
76
  "react": "^19.0.0",
72
77
  "three": "^0.180.0"
73
78
  },